package playletser import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "videoweb/app/api/proto" "videoweb/app/service/configser" "videoweb/app/service/playletcache" "videoweb/app/service/reportser" "videoweb/app/service/videoser" "videoweb/pub/common/enum/redisconst" "videoweb/pub/common/log" "videoweb/pub/common/netutil" "videoweb/pub/common/srvdef" "videoweb/pub/common/utils" "videoweb/pub/esmanager" "videoweb/pub/models/cointransmod" "videoweb/pub/models/esmod" "videoweb/pub/models/homeconfigmod" "videoweb/pub/models/hotsearchmod" "videoweb/pub/models/playletchaptercollectmod" "videoweb/pub/models/playletchapterlikemod" "videoweb/pub/models/playletchaptermod" "videoweb/pub/models/playletchapterunlockmod" "videoweb/pub/models/playletcollectmod" "videoweb/pub/models/playletlikemod" "videoweb/pub/models/playletmod" "videoweb/pub/models/playletthememod" "videoweb/pub/models/playletwatchhistorymod" "videoweb/pub/models/playletwatchprogressmod" "videoweb/pub/models/usermod" "videoweb/pub/models/videoinformationmod" "videoweb/pub/mongos" "videoweb/pub/payment" "videoweb/pub/pubdef" "videoweb/pub/redis" "videoweb/pub/seo/h5path" "videoweb/web/service/srv_search" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) const ( homeBannerLimit int64 = 30 // banner图限制 homeConfigPlayletLimit int64 = 12 // 首页配置短剧数量限制 homeConfigThemeLimit int = 3 // 首页配置题材数量限制 homeConfigVideoLimit int64 = 12 // 首页配置视频数量限制 homeConfigDetailSize int64 = 10 // 首页配置详情默认分页条数 defaultPageSize int64 = 10 // 默认分页条数 guessLikePageSize int64 = 6 // 猜你喜欢默认分页条数 hotRankPageSize int64 = 20 // 热榜默认分页条数 recommendPageSize int64 = 20 // 今日推荐/热门好剧默认分页条数 maxPageSize int64 = 100 // 最大分页条数 allListSortLatest int64 = 1 // 最新 allListSortHot int64 = 2 // 最热 allListSortCollect int64 = 3 // 收藏最多 topicListSortLatest int64 = 1 // 最新 topicListSortHot int64 = 2 // 最热 topicListPlayletLimit int64 = 3 // 专题卡片短剧数量限制 topicDetailSortEdit int64 = 1 // 编辑排序 topicDetailSortLatest int64 = 2 // 最新 topicDetailSortHot int64 = 3 // 最热 topicDetailPageSize int64 = 12 // 专题详情默认分页条数 topicDetailCoverLimit int64 = 3 // 专题详情顶部封面数量限制 topicDetailMoreLimit int64 = 3 // 更多专题数量限制 operateFilterLatest int64 = 1 // 最近更新 operateFilterHot int64 = 2 // 当前热播 operateFilterOriginal int64 = 3 // 独家原创 rankHot int64 = 1 // 热播榜 rankNew int64 = 2 // 新剧榜 rankCollect int64 = 3 // 追剧榜 operateStatusYes int64 = 1 // 已操作 operateStatusNo int64 = 2 // 未操作 playletHeatClickInc int64 = 100 // 每次查看详情增加的热度值 playletHeatLikeInc int64 = 1 // 点赞增加的热度值 playletHeatCollectInc int64 = 2 // 收藏增加的热度值 playletHeatBuyInc int64 = 10 // 购买剧集增加的短剧热度值 playletHeatForwardInc int64 = 5 // 转发增加的热度值 homePlayletCacheSchema = "playlet-banner-home-config-category-v5" // playletH5DetailPath 短剧Web/H5播放页路径,供SEO canonical使用。 playletH5DetailPath = "/play" // playletChapterH5DetailPath 短剧Web/H5剧集播放页路径,供SEO canonical使用。 playletChapterH5DetailPath = "/play" ) var errPlayletChapterAlreadyUnlocked = errors.New("playlet chapter already unlocked") type playletChapterAccessContext struct { balance int64 vipValid bool unlockedChapterIds map[int64]bool } type homeConfigThemePlaylets struct { Theme *playletcache.ThemeItem Playlets playletmod.PlayletList } type playletBoughtAggRow struct { PlayletId int64 `bson:"_id"` BoughtChapterCount int64 `bson:"boughtChapterCount"` CostCoins int64 `bson:"costCoins"` BoughtAt time.Time `bson:"boughtAt"` LatestUnlockId int64 `bson:"latestUnlockId"` } type playletBoughtCountRow struct { Count int64 `bson:"count"` } // playletHomeProjection 查询指定字段 var playletHomeProjection = bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.Status, Value: 1}, {Key: playletmod.FN.Playlet.IsFeatured, Value: 1}, {Key: playletmod.FN.Playlet.IsRecommend, Value: 1}, {Key: playletmod.FN.Playlet.ShowOnBanner, Value: 1}, {Key: playletmod.FN.Playlet.Banner, 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.ThemeIds, Value: 1}, {Key: playletmod.FN.Playlet.CategoryIds, Value: 1}, {Key: playletmod.FN.Playlet.Title, Value: 1}, {Key: playletmod.FN.Playlet.PlayName, Value: 1}, {Key: playletmod.FN.Playlet.Description, 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.HorizontalCover, Value: 1}, {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, {Key: playletmod.FN.Playlet.CompressedHorizontalCover, Value: 1}, {Key: playletmod.FN.Playlet.Preview, Value: 1}, {Key: playletmod.FN.Playlet.TagIds, Value: 1}, {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, {Key: playletmod.FN.Playlet.UpdateStatus, Value: 1}, {Key: playletmod.FN.Playlet.CountBrowse, Value: 1}, {Key: playletmod.FN.Playlet.HeatValue, 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.IsOriginal, Value: 1}, {Key: playletmod.FN.Playlet.OperateType, Value: 1}, {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, {Key: "seoInfo", Value: 1}, } // homeConfigAppProjection 查询首页配置指定字段 var homeConfigAppProjection = bson.D{ {Key: homeconfigmod.FN.HomeConfig.Id, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.Name, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.Slug, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.Desc, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.Type, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.Rank, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.SortType, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.Status, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Title, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Description, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Keyworld, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.CreatedAt, Value: 1}, {Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: 1}, } // homeConfigVideoProjection 查询首页配置视频指定字段 var homeConfigVideoProjection = 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.AddedTime, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.CreatorId, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.CreatorName, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Keyworld, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1}, {Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1}, } // playletCollectListProjection 我的收藏列表只读取卡片展示和未看集数计算需要的字段。 var playletCollectListProjection = bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.Title, Value: 1}, {Key: playletmod.FN.Playlet.PlayName, Value: 1}, {Key: playletmod.FN.Playlet.Slug, Value: 1}, {Key: playletmod.FN.Playlet.Cover, Value: 1}, {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, {Key: playletmod.FN.Playlet.TagIds, Value: 1}, {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, {Key: playletmod.FN.Playlet.UpdateStatus, Value: 1}, {Key: playletmod.FN.Playlet.CountBrowse, Value: 1}, {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, {Key: playletmod.FN.Playlet.CountCollect, Value: 1}, {Key: playletmod.FN.Playlet.CountLike, Value: 1}, {Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1}, {Key: "seoInfo", Value: 1}, } // playletChapterSEOProjection 查询剧集详情SEO需要的短剧字段,避免拉取整部短剧大字段。 var playletChapterSEOProjection = bson.D{ {Key: playletmod.FN.Playlet.Id, 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.IsOriginal, Value: 1}, {Key: "seoInfo", Value: 1}, } // playletChapterInfoProjection 查询详情章节集合需要的字段 var playletChapterInfoProjection = bson.D{ {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Md5, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Hash, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Height, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Width, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Ratio, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.MediaSize, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1}, } // playletChapterDetailProjection 查询剧集详情需要的字段 var playletChapterDetailProjection = bson.D{ {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Md5, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Hash, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Height, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Width, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Ratio, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.MediaSize, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CountComment, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CountBrowse, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CountLike, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.WatermarkStatus, Value: 1}, } // playletOperateProjection 查询短剧操作接口需要的字段 var playletOperateProjection = bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.CountLike, Value: 1}, {Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1}, {Key: playletmod.FN.Playlet.CountCollect, Value: 1}, } // playletChapterOperateProjection 查询剧集操作接口需要的字段 var playletChapterOperateProjection = bson.D{ {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CountLike, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1}, } // playletChapterWatchReportProjection 观看上报只需要剧集归属和集数。 var playletChapterWatchReportProjection = bson.D{ {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, } // playletWatchReportProjection 观看上报只需要短剧总集数计算未看数量。 var playletWatchReportProjection = bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, } // Home 查询app首页短剧数据 func Home(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) { cacheKey := homePlayletCacheKey() if resp := getHomePlayletCache(cacheKey); resp != nil { attachHomeAnnouncements(resp) attachHomeAdvitise(resp) return resp, nil } resp, err := buildHomePlayletResponse(ctx) if err != nil { return nil, err } setHomePlayletCache(cacheKey, resp) attachHomeAnnouncements(resp) attachHomeAdvitise(resp) return resp, nil } // HomeConfigDetailList 分页查询首页配置详情数据 func HomeConfigDetailList(ctx context.Context, req *proto.HomeConfigDetailListReq) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "首页配置无效", "") } config, err := findEnabledHomeConfig(ctx, req.HomeConfigId, req.HomeConfigSlug) if err != nil { return nil, err } pageNum, pageSize := normalizeHomeConfigDetailPage(req.PageNum, req.PageSize) resp := &proto.HomeConfigDetailListResp{ HomeConfigId: config.Id, HomeConfigSlug: config.Slug, Name: config.Name, Desc: config.Desc, Type: config.Type, SortType: config.SortType, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]*proto.CommonData, 0), VideoList: make([]*proto.VideoItemResp, 0), ThemeList: make([]*proto.HomeConfigThemeData, 0), Advitise: configser.GetAdvitise(), SeoInfo: config.SeoInfo, } switch config.Type { case homeconfigmod.TypePlaylet: return fillHomeConfigPlayletDetail(ctx, resp, config.SortType, req, pageNum, pageSize) case homeconfigmod.TypeVideo: return fillHomeConfigVideoDetail(ctx, resp, config.SortType, req, pageNum, pageSize) case homeconfigmod.TypeTheme: return fillHomeConfigThemeDetail(ctx, resp, config.SortType, pageNum, pageSize) default: return nil, srvdef.New(srvdef.ErrParamError, "类型无效", "") } } // AllList 分页查询app端全部短剧列表 func AllList(ctx context.Context, req *proto.PlayletAllListReq) (*proto.PlayletAllListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } themeID, err := resolveThemeID(ctx, req.ThemeId, req.ThemeSlug) if err != nil { return nil, err } req.ThemeId = themeID filter, err := playletAllListFilter(req) if err != nil { return nil, err } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletAllListResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.CommonData, 0), Advitise: configser.GetAdvitise(), } if req.ThemeId > 0 { resp.SeoInfo = buildPlayletThemePageSeoInfo(req.ThemeId) } else { resp.SeoInfo = buildPlayletListPageSeoInfo() } if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } list, err := findHomePlaylets(ctx, filter, playletAllListSort(req.SortType), pageSize, options.Find().SetSkip(skip)) if err != nil { return nil, err } themeNameMap, err := buildHomeThemeNameMap(ctx, list) if err != nil { log.Error("查询全部短剧列表题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, list) if err != nil { log.Error("查询全部短剧列表标签名称失败", log.E(err)) return nil, err } resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) return resp, nil } // TopicList 分页查询app端专题剧单 func TopicList(ctx context.Context, req *proto.PlayletTopicPageListReq) (*proto.PlayletTopicPageListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) skip := (pageNum - 1) * pageSize groups, totalCount, playlets, err := findPlayletTopicPage(ctx, req.SortType, skip, pageSize) if err != nil { return nil, err } resp := &proto.PlayletTopicPageListResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]*proto.PlayletTopicPageItem, 0), SeoInfo: buildPlayletListPageSeoInfo(), } if totalCount == 0 || len(groups) == 0 { return resp, nil } themeNameMap, err := buildHomeThemeNameMap(ctx, playlets) if err != nil { log.Error("查询专题剧单题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, playlets) if err != nil { log.Error("查询专题剧单标签名称失败", log.E(err)) return nil, err } resp.List = playletTopicsToPageData(ctx, groups, themeNameMap, tagNameMap) return resp, nil } // TopicDetail 查询app端专题详情 func TopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) { if req == nil || req.Id <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "专题ID无效", "") } theme := playletcache.GetEnabledTheme(req.Id) if theme == nil { return nil, srvdef.New(srvdef.ErrNoData, "专题不存在或已下架", "专题不存在或已下架") } pageNum, pageSize := normalizePlayletTopicDetailPage(req.PageNum, req.PageSize) filter := homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.ThemeIds: req.Id, }) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletTopicDetailResp{ Id: theme.Id, Name: theme.Name, Slug: theme.Slug, Desc: strings.TrimSpace(theme.SeoInfo.Description), Icon: withImageDomain(ctx, theme.Icon), Rank: theme.Rank, PlayletCount: totalCount, Covers: make([]string, 0), TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.CommonData, 0), MoreTopics: make([]*proto.PlayletTopicPageItem, 0), SeoInfo: buildPageSeoInfoFromValue(theme.SeoInfo, theme.Name, "", buildPlayletThemeListCanonicalURL(theme.Slug, theme.Id)), } if totalCount == 0 { return fillPlayletTopicDetailMore(ctx, resp, theme.Id, nil) } coverList, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), topicDetailCoverLimit) if err != nil { return nil, err } skip := (pageNum - 1) * pageSize pageList := make(playletmod.PlayletList, 0) if skip < totalCount { pageList, err = findHomePlaylets(ctx, filter, playletTopicDetailSort(req.SortType), pageSize, options.Find().SetSkip(skip)) if err != nil { return nil, err } } allPlaylets := mergeHomePlayletLists(coverList, pageList) resp, err = fillPlayletTopicDetailMore(ctx, resp, theme.Id, allPlaylets) if err != nil { return nil, err } themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets) if err != nil { log.Error("查询专题详情题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets) if err != nil { log.Error("查询专题详情标签名称失败", log.E(err)) return nil, err } resp.Covers = playletTopicCovers(playletsToHomeCommonData(ctx, coverList, themeNameMap, tagNameMap)) resp.List = commonDataValues(playletsToHomeCommonData(ctx, pageList, themeNameMap, tagNameMap)) return resp, nil } // Rank 分页查询app端短剧排行榜 func Rank(ctx context.Context, req *proto.PlayletRankReq) (*proto.PlayletRankResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) filter := homeUpPlayletFilter(nil) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletRankResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.CommonData, 0), } resp.SeoInfo = buildPlayletListPageSeoInfo() if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } list, err := findHomePlaylets(ctx, filter, playletRankSort(req.SortOrder), pageSize, options.Find().SetSkip(skip)) if err != nil { return nil, err } themeNameMap, err := buildHomeThemeNameMap(ctx, list) if err != nil { log.Error("查询短剧排行榜题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, list) if err != nil { log.Error("查询短剧排行榜标签名称失败", log.E(err)) return nil, err } resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) return resp, nil } // HotRankList 分页查询app端热榜短剧列表 func HotRankList(ctx context.Context, req *proto.PlayletHotRankListReq) (*proto.PlayletHotRankListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } rankType := normalizePlayletHotRankType(req.RankType) if rankType == 0 { return nil, srvdef.New(srvdef.ErrParamError, "榜单类型无效", "") } pageNum, pageSize := normalizePlayletHotRankPage(req.PageNum, req.PageSize) filter := homeUpPlayletFilter(nil) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletHotRankListResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), RankType: rankType, List: make([]proto.CommonData, 0), SeoInfo: buildPlayletListPageSeoInfo(), } if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } list, err := findHomePlaylets(ctx, filter, playletHotRankSort(rankType), pageSize, options.Find().SetSkip(skip)) if err != nil { return nil, err } themeNameMap, err := buildHomeThemeNameMap(ctx, list) if err != nil { log.Error("查询热榜题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, list) if err != nil { log.Error("查询热榜标签名称失败", log.E(err)) return nil, err } resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) return resp, nil } // TodayUpdate 分页查询app端今日更新短剧列表 func TodayUpdate(ctx context.Context, req *proto.PlayletTodayUpdateReq) (*proto.PlayletTodayUpdateResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) filter := homeUpPlayletFilter(nil) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletTodayUpdateResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.CommonData, 0), } resp.SeoInfo = buildPlayletListPageSeoInfo() if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } list, err := findHomePlaylets(ctx, filter, playletTodayUpdateSort(), pageSize, options.Find().SetSkip(skip)) if err != nil { return nil, err } themeNameMap, err := buildHomeThemeNameMap(ctx, list) if err != nil { log.Error("查询今日更新短剧题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, list) if err != nil { log.Error("查询今日更新短剧标签名称失败", log.E(err)) return nil, err } resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) return resp, nil } // OperateList 按运营类型分页查询短剧,支持最近更新、当前热播、独家原创。 func OperateList(ctx context.Context, req *proto.PlayletOperateListReq) (*proto.PlayletOperateListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } operateType := playletmod.OperateType(req.OperateType) if !playletmod.IsValidOperateType(operateType) { return nil, srvdef.New(srvdef.ErrParamError, "运营类型无效", "") } filterType := req.FilterType if filterType == 0 { filterType = operateFilterLatest } if filterType != operateFilterLatest && filterType != operateFilterHot && filterType != operateFilterOriginal { return nil, srvdef.New(srvdef.ErrParamError, "筛选类型无效", "") } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) filter := homeUpPlayletFilter(operateTypeFilter(operateType)) if filterType == operateFilterOriginal { filter[playletmod.FN.Playlet.IsRecommend] = true } totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletOperateListResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), OperateType: int32(operateType), FilterType: filterType, List: make([]proto.CommonData, 0), } if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } list, err := findHomePlaylets(ctx, filter, playletOperateListSort(filterType), pageSize, options.Find().SetSkip(skip)) if err != nil { return nil, err } themeNameMap, err := buildHomeThemeNameMap(ctx, list) if err != nil { log.Error("查询运营类型短剧题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, list) if err != nil { log.Error("查询运营类型短剧标签名称失败", log.E(err)) return nil, err } resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) return resp, nil } // TagVideoList 根据标签ID分页查询app端短剧列表 func TagVideoList(ctx context.Context, req *proto.PlayletTagVideoReq) (*proto.PlayletTagVideoResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "标签ID不能为空") } tagID, err := resolveTagID(ctx, req.TagId, req.TagSlug) if err != nil { return nil, err } req.TagId = tagID pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) filter := homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.TagIds: req.TagId, }) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletTagVideoResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.CommonData, 0), } resp.SeoInfo = buildPlayletTagPageSeoInfo(req.TagId) if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } list, err := findHomePlaylets(ctx, filter, playletAllListSort(req.SortType), pageSize, options.Find().SetSkip(skip)) if err != nil { return nil, err } themeNameMap, err := buildHomeThemeNameMap(ctx, list) if err != nil { log.Error("查询标签短剧题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, list) if err != nil { log.Error("查询标签短剧标签名称失败", log.E(err)) return nil, err } resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) return resp, nil } // GuessLikeList 分页查询同题材猜你喜欢短剧 func GuessLikeList(ctx context.Context, req *proto.PlayletGuessLikeListReq) (*proto.PlayletGuessLikeListResp, *srvdef.SrvErr) { if req == nil || req.PlayletId <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") } current, err := findGuessLikeCurrentPlaylet(ctx, req.PlayletId) if err != nil { return nil, err } pageNum, pageSize := normalizePlayletGuessLikePage(req.PageNum, req.PageSize) resp := &proto.PlayletGuessLikeListResp{ PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.CommonData, 0), } if current == nil || len(current.ThemeIds) == 0 { return resp, nil } filter := playletGuessLikeFilter(current) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp.TotalCount = totalCount if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } list, err := findHomePlaylets(ctx, filter, playletGuessLikeSort(), pageSize, options.Find().SetSkip(skip)) if err != nil { return nil, err } themeNameMap, err := buildHomeThemeNameMap(ctx, list) if err != nil { log.Error("查询猜你喜欢题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, list) if err != nil { log.Error("查询猜你喜欢标签名称失败", log.E(err)) return nil, err } resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) return resp, nil } // Search 使用ES分页搜索app端短剧 func Search(ctx context.Context, req *proto.PlayletSearchReq) (*proto.PlayletSearchResp, *srvdef.SrvErr) { // 校验搜索关键词,避免空关键词扫全量ES索引。 if req == nil || strings.TrimSpace(req.Keyword) == "" { return nil, srvdef.New(srvdef.ErrParamError, "搜索关键词不能为空", "搜索关键词不能为空") } if esmanager.Instance == nil { log.Error("短剧搜索ES未初始化") return nil, srvdef.New(srvdef.ErrESSearchError, "", "") } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) keyword := strings.TrimSpace(req.Keyword) if pageNum == 1 { recordHotSearch(keyword) } query := playletSearchQuery(keyword, (pageNum-1)*pageSize, pageSize) var hits esmod.ESPlayletHits if err := esmanager.Instance.Search(esmod.GetPlayletIndex(), &hits, query); err != nil { if strings.Contains(err.Error(), "index_not_found_exception") { log.Warn("短剧ES索引不存在,返回空搜索结果", log.Any("关键词", keyword), log.Any("索引", esmod.GetPlayletIndex())) return emptyPlayletSearchResp(ctx, keyword, pageNum, pageSize), nil } log.Error("短剧ES搜索失败", log.Any("关键词", keyword), log.E(err)) return nil, srvdef.New(srvdef.ErrESSearchError, "", "") } resp := &proto.PlayletSearchResp{ TotalCount: hits.Total.Value, PageSize: int(pageSize), PageNum: int(pageNum), Keyword: keyword, List: make([]proto.CommonData, 0, len(hits.Hits)), EveryoneWatching: make([]*proto.CommonData, 0), } if hits.Total.Value == 0 { resp.EveryoneWatching = guessLikeCommonData(ctx) return resp, nil } for _, hit := range hits.Hits { if hit == nil || hit.Source == nil { continue } resp.List = append(resp.List, esPlayletToCommonData(ctx, hit.Source)) } return resp, nil } // SearchSuggest 查询短剧搜索框联想数据 func SearchSuggest(ctx context.Context, req *proto.PlayletSearchSuggestReq) (*proto.PlayletSearchSuggestResp, *srvdef.SrvErr) { if req == nil || strings.TrimSpace(req.Keyword) == "" { return nil, srvdef.New(srvdef.ErrParamError, "搜索关键词不能为空", "搜索关键词不能为空") } keyword := strings.TrimSpace(req.Keyword) resp := &proto.PlayletSearchSuggestResp{ Keyword: keyword, List: make([]proto.CommonData, 0), } if esmanager.Instance == nil { return resp, nil } query := playletSearchQuery(keyword, 0, maxPageSize) var hits esmod.ESPlayletHits if err := esmanager.Instance.Search(esmod.GetPlayletIndex(), &hits, query); err != nil { log.Warn("短剧搜索联想ES查询失败", log.Any("关键词", keyword), log.E(err)) return resp, nil } for _, hit := range hits.Hits { if hit == nil || hit.Source == nil { continue } resp.List = append(resp.List, esPlayletToCommonData(ctx, hit.Source)) } return resp, nil } func recordHotSearch(keyword string) { keyword = hotsearchmod.NormalizeKeyword(keyword) if keyword == "" { return } go func(word string) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := hotsearchmod.IncrByKeyword(ctx, word); err != nil { log.Warn("记录热搜词失败", log.Any("关键词", word), log.E(err)) } }(keyword) } // HotLike 返回缓存的热搜词和猜你喜欢。 func HotLike(ctx context.Context) *proto.PlayletHotLikeResp { hotSearch := playletcache.ListHotSearch() resp := &proto.PlayletHotLikeResp{ HotSearch: make([]proto.PlayletHotSearchItem, 0, len(hotSearch)), GuessLike: guessLikeCommonData(ctx), } for _, item := range hotSearch { resp.HotSearch = append(resp.HotSearch, proto.PlayletHotSearchItem{ Keyword: item.Keyword, Count: item.Count, }) } return resp } // SetLike 设置当前用户短剧点赞状态 func SetLike(ctx context.Context, userId int64, req *proto.PlayletLikeSetReq) (*proto.PlayletLikeSetResp, *srvdef.SrvErr) { // 使用指针布尔值校验是否传参,保证 false 能表示取消点赞。 if req == nil || req.Id <= 0 || req.IsLike == nil { return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") } playlet, changed, err := setPlayletLike(ctx, userId, req.Id, *req.IsLike) if err != nil { return nil, err } countLike := displayPlayletCountLike(applyLocalCounter(playlet.CountLike, *req.IsLike, changed), playlet.VirtualCountLike) resp := &proto.PlayletLikeSetResp{ Id: req.Id, IsLike: operateStatusByFlag(*req.IsLike), CountLike: countLike, } if changed { reportser.ReportPlayletLike(ctx, userId, req.Id, resp.IsLike) } return resp, nil } // SetCollect 设置当前用户短剧收藏状态 func SetCollect(ctx context.Context, userId int64, req *proto.PlayletCollectSetReq) (*proto.PlayletCollectSetResp, *srvdef.SrvErr) { // 使用指针布尔值校验是否传参,保证 false 能表示取消收藏。 if req == nil || req.Id <= 0 || req.IsCollect == nil { return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") } playlet, changed, err := setPlayletCollect(ctx, userId, req.Id, *req.IsCollect) if err != nil { return nil, err } countCollect := applyLocalCounter(playlet.CountCollect, *req.IsCollect, changed) resp := &proto.PlayletCollectSetResp{ Id: req.Id, IsCollect: operateStatusByFlag(*req.IsCollect), CountCollect: countCollect, } if changed { reportser.ReportPlayletCollect(ctx, userId, req.Id, resp.IsCollect) } return resp, nil } // ForwardHeat 短剧转发增加热度值 func ForwardHeat(ctx context.Context, userId int64, ip string, req *proto.PlayletForwardHeatReq) (*proto.PlayletForwardHeatResp, *srvdef.SrvErr) { if req == nil || req.Id <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") } limited, limitErr := checkPlayletForwardHeatLimit(userId, ip, req.Id) if limitErr != nil { log.Error("短剧转发热度频控失败", log.Any("用户ID", userId), log.Any("IP", ip), log.Any("短剧ID", req.Id), log.E(limitErr)) } if limited { playlet, srvErr := findPlayletForwardHeatTarget(ctx, req.Id) if srvErr != nil { return nil, srvErr } return &proto.PlayletForwardHeatResp{ Id: playlet.Id, HeatValue: playlet.HeatValue, }, nil } playlet, srvErr := incrementPlayletHeatValueAndReturn(ctx, req.Id, playletHeatForwardInc) if srvErr != nil { log.Error("短剧转发增加热度失败", log.Any("短剧ID", req.Id), log.E(srvErr)) return nil, srvErr } return &proto.PlayletForwardHeatResp{ Id: playlet.Id, HeatValue: playlet.HeatValue, }, nil } // SetChapterLike 设置当前用户短剧剧集点赞状态 func SetChapterLike(ctx context.Context, userId int64, req *proto.PlayletChapterLikeSetReq) (*proto.PlayletChapterLikeSetResp, *srvdef.SrvErr) { // 使用指针布尔值校验是否传参,保证 false 能表示取消点赞。 if req == nil || req.ChapterId <= 0 || req.IsLike == nil { return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") } chapter, changed, err := setPlayletChapterLike(ctx, userId, req.ChapterId, *req.IsLike) if err != nil { return nil, err } countLike := applyLocalCounter(chapter.CountLike, *req.IsLike, changed) resp := &proto.PlayletChapterLikeSetResp{ ChapterId: req.ChapterId, IsLike: operateStatusByFlag(*req.IsLike), CountLike: countLike, } if changed { reportser.ReportPlayletChapterLike(ctx, userId, chapter.PlayletId, req.ChapterId, resp.IsLike) } return resp, nil } // SetChapterCollect 设置当前用户短剧剧集收藏状态 func SetChapterCollect(ctx context.Context, userId int64, req *proto.PlayletChapterCollectSetReq) (*proto.PlayletChapterCollectSetResp, *srvdef.SrvErr) { // 使用指针布尔值校验是否传参,保证 false 能表示取消收藏。 if req == nil || req.ChapterId <= 0 || req.IsCollect == nil { return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") } chapter, changed, err := setPlayletChapterCollect(ctx, userId, req.ChapterId, *req.IsCollect) if err != nil { return nil, err } countCollect := applyLocalCounter(chapter.CountCollect, *req.IsCollect, changed) resp := &proto.PlayletChapterCollectSetResp{ ChapterId: req.ChapterId, IsCollect: operateStatusByFlag(*req.IsCollect), CountCollect: countCollect, } if changed { reportser.ReportPlayletChapterCollect(ctx, userId, chapter.PlayletId, req.ChapterId, resp.IsCollect) } return resp, nil } // ReportChapterWatch 上报当前用户短剧剧集观看进度 func ReportChapterWatch(ctx context.Context, userId int64, req *proto.PlayletChapterWatchReportReq) (*proto.PlayletChapterWatchReportResp, *srvdef.SrvErr) { if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } if req == nil || req.ChapterId <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") } filter := bson.M{ playletchaptermod.FN.PlayletChapter.Id: req.ChapterId, } if req.PlayletId > 0 { filter[playletchaptermod.FN.PlayletChapter.PlayletId] = req.PlayletId } chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, filter, options.FindOne().SetProjection(playletChapterWatchReportProjection)) if err != nil { log.Error("查询短剧观看上报剧集失败", log.Any("用户ID", userId), log.Any("短剧ID", req.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err)) return nil, err } if chapter.CurrentEpisode <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "剧集集数无效", "剧集集数无效") } playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: chapter.PlayletId, }), options.FindOne().SetProjection(playletWatchReportProjection)) if err != nil { log.Error("查询短剧观看上报所属短剧失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err)) return nil, err } progress, err := playletwatchprogressmod.SavePlayletWatchProgress(ctx, userId, chapter.PlayletId, chapter.Id, chapter.CurrentEpisode, playlet.TotalEpisode, true) if err != nil { log.Error("保存短剧观看上报进度失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err)) return nil, err } if err = playletwatchhistorymod.SavePlayletWatchHistory(ctx, userId, chapter.PlayletId, chapter.Id, chapter.CurrentEpisode); err != nil { log.Error("保存短剧观看历史失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err)) return nil, err } watchedCount := collectWatchedCount(progress, playlet.TotalEpisode) return &proto.PlayletChapterWatchReportResp{ PlayletId: chapter.PlayletId, ChapterId: chapter.Id, CurrentEpisode: chapter.CurrentEpisode, WatchedCount: watchedCount, UnwatchedCount: collectUnwatchedCount(playlet.TotalEpisode, watchedCount), LastChapterId: collectLastChapterId(progress), LastEpisode: collectLastEpisode(progress), }, nil } // ChapterDetail 根据ID查询app端短剧剧集详情 func ChapterDetail(ctx context.Context, userId int64, req *proto.PlayletChapterInfoReq) (*proto.PlayletChapterInfoResp, *srvdef.SrvErr) { // 播放页切换剧集只查询当前剧集和所属短剧状态,不重复查询整部短剧详情、相关推荐和全部剧集列表。 if req == nil || req.ChapterId <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "短剧剧集ID不能为空", "短剧剧集ID不能为空") } playletID, err := resolvePlayletID(ctx, req.PlayletId, req.PlayletSlug, false) if err != nil { return nil, err } req.PlayletId = playletID filter := bson.M{ playletchaptermod.FN.PlayletChapter.Id: req.ChapterId, } if req.PlayletId > 0 { filter[playletchaptermod.FN.PlayletChapter.PlayletId] = req.PlayletId } chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, filter, options.FindOne().SetProjection(playletChapterDetailProjection)) if err != nil { log.Error("查询短剧剧集详情失败", log.Any("短剧ID", req.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err)) return nil, err } playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: chapter.PlayletId, }), options.FindOne().SetProjection(playletChapterSEOProjection)) if err != nil { log.Error("查询短剧剧集详情所属短剧失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err)) return nil, err } if err = ensureOriginalChapterWatermarkReady(playlet, chapter); err != nil { return nil, err } isLike, isCollect, err := findPlayletChapterUserOperateStatus(ctx, userId, chapter.Id) if err != nil { return nil, err } resp := playletChapterToInfoResp(chapter) resp.IsLike = isLike resp.IsCollect = isCollect resp.SeoInfo = buildPlayletChapterDetailSeoInfo(playlet, chapter) if err = applyPlayletChapterWatchPermission(ctx, userId, chapter, resp); err != nil { return nil, err } if !resp.CanWatch { resp.VideoUrl = "" return resp, nil } if err = incrementPlayletChapterBrowse(ctx, chapter.Id); err != nil { log.Error("增加短剧剧集观看量失败", log.Any("剧集ID", chapter.Id), log.E(err)) return nil, err } resp.CountBrowse++ if resp.VideoUrl != "" { resp.VideoUrl = videoser.GetPlayletM3U8AuthParam( chapter.Id, resp.VideoUrl, videoser.H5M3U8RequestIP(ctx), strings.TrimSpace(chapter.WatermarkVideoUrl) == "", userId, ) } return resp, nil } // ChapterList 根据短剧ID查询app端短剧剧集列表 func ChapterList(ctx context.Context, req *proto.PlayletChapterListReq) (*proto.PlayletChapterListResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") } playletID, err := resolvePlayletID(ctx, req.PlayletId, req.PlayletSlug, true) if err != nil { return nil, err } req.PlayletId = playletID playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: req.PlayletId, }), options.FindOne().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.IsOriginal, Value: 1}, })) if err != nil { log.Error("查询短剧剧集列表所属短剧失败", log.Any("短剧ID", req.PlayletId), log.E(err)) return nil, err } chapterInfo, err := findPlayletChapters(ctx, req.PlayletId, 0, playlet != nil && playlet.IsOriginal) if err != nil { return nil, err } return &proto.PlayletChapterListResp{ PlayletId: req.PlayletId, List: chapterInfo, }, nil } // UnlockChapter 使用金币逐集解锁短剧剧集。 // // 幂等性约束: // 1. 事务前先查一次已解锁记录,命中时直接返回成功; // 2. 事务内先插入 userId+chapterId 唯一解锁记录,再扣金币; // 3. 并发重复请求只有一个能插入成功,其他请求按已解锁成功处理,不重复扣费。 func UnlockChapter(ctx context.Context, userId int64, req *proto.PlayletChapterUnlockReq) (*proto.PlayletChapterUnlockResp, *srvdef.SrvErr) { if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } if req == nil || req.PlayletId <= 0 || req.ChapterId <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "短剧ID和剧集ID不能为空", "短剧ID和剧集ID不能为空") } chapter, playlet, err := findPlayletChapterForUnlock(ctx, req.PlayletId, req.ChapterId) if err != nil { return nil, err } if chapter.PayType == playletchaptermod.PayTypeFree { user, err := findPlayletChapterAccessUser(ctx, userId) if err != nil { return nil, err } return &proto.PlayletChapterUnlockResp{ PlayletId: chapter.PlayletId, ChapterId: chapter.Id, Unlocked: true, CanWatch: true, Balance: user.Recharge, CostCoins: 0, ChangeCoin: 0, }, nil } if chapter.PayType == playletchaptermod.PayTypeVip { return nil, srvdef.New(srvdef.ErrParamError, "VIP剧集需开通会员后观看", "VIP剧集需开通会员后观看") } if chapter.PayType != playletchaptermod.PayTypeCoin { return nil, srvdef.New(srvdef.ErrParamError, "剧集支付方式无效", "剧集支付方式无效") } if chapter.SingleCoins <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "剧集金币价格无效", "剧集金币价格无效") } unlocked, err := playletchapterunlockmod.ExistsPlayletChapterUnlock(ctx, userId, chapter.Id) if err != nil { return nil, err } user, err := findPlayletChapterAccessUser(ctx, userId) if err != nil { return nil, err } if unlocked { return playletChapterUnlockResp(chapter, user.Recharge, 0), nil } if user.Recharge < chapter.SingleCoins { return nil, srvdef.New(srvdef.UserNotEnoughMoney, "金币不足,请先充值", "金币不足,请先充值") } orderNo := playletChapterUnlockOrderNo(userId, chapter.Id) var txSrvErr *srvdef.SrvErr var balanceAfter int64 txErr := mongos.Shop.Transaction(ctx, func(sctx mongo.SessionContext) error { inserted, srvErr := playletchapterunlockmod.InsertOnePlayletChapterUnlock(sctx, &playletchapterunlockmod.PlayletChapterUnlock{ UserId: userId, PlayletId: chapter.PlayletId, ChapterId: chapter.Id, Coins: chapter.SingleCoins, OrderNo: orderNo, }) if srvErr != nil { txSrvErr = srvErr return srvErr } if !inserted { return errPlayletChapterAlreadyUnlocked } updatedUser, srvErr := usermod.UserPay(sctx, userId, chapter.SingleCoins) if srvErr != nil { txSrvErr = srvdef.New(srvdef.UserNotEnoughMoney, "金币不足,请先充值", "金币不足,请先充值") return txSrvErr } balanceAfter = updatedUser.Recharge srvErr = payment.WriteCoinTrans(sctx, payment.WriteCoinTransOpt{ UserId: userId, UserName: updatedUser.NickName, FlowDir: cointransmod.FlowOut, TranType: cointransmod.TranTypeVideoUnlock, Amount: chapter.SingleCoins, OrderNo: orderNo, RelatedId: chapter.Id, Remark: fmt.Sprintf("解锁《%s》", playlet.Title), }, balanceAfter) if srvErr != nil { txSrvErr = srvErr return srvErr } return nil }) if txErr != nil { if errors.Is(txErr, errPlayletChapterAlreadyUnlocked) { user, err = findPlayletChapterAccessUser(ctx, userId) if err != nil { return nil, err } return playletChapterUnlockResp(chapter, user.Recharge, 0), nil } if txSrvErr != nil { return nil, txSrvErr } log.Error("短剧剧集金币解锁事务失败", log.Any("userId", userId), log.Any("chapterId", chapter.Id), log.E(txErr)) return nil, srvdef.New(srvdef.DBTransaction, "解锁失败,请稍后重试", "解锁失败,请稍后重试") } updatePlayletChapterUnlockStatsAsync(chapter.PlayletId, chapter.Id) reportser.ReportPlayletUnlock(ctx, user, playlet, chapter, chapter.SingleCoins, user.Recharge, balanceAfter, orderNo) return playletChapterUnlockResp(chapter, balanceAfter, -chapter.SingleCoins), nil } // updatePlayletChapterUnlockStatsAsync 异步更新剧集购买数和短剧热度 func updatePlayletChapterUnlockStatsAsync(playletId int64, chapterId int64) { if playletId <= 0 || chapterId <= 0 { return } go func() { if err := playletchaptermod.UpdateOnePlayletChapter(context.Background(), bson.M{ playletchaptermod.FN.PlayletChapter.Id: chapterId, }, bson.M{ "$inc": bson.M{playletchaptermod.FN.PlayletChapter.CountBuy: 1}, }); err != nil { log.Error("短剧剧集解锁后更新购买数失败", log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) } if err := incrementPlayletHeatValue(context.Background(), playletId, playletHeatBuyInc); err != nil { log.Error("短剧剧集解锁后更新短剧热度失败", log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) return } syncPlayletToESAsync(playletId, "短剧剧集解锁后同步ES失败") }() } // applyPlayletChapterWatchPermission 根据剧集支付方式填充播放权限,并在未授权时清空播放地址。 func applyPlayletChapterWatchPermission(ctx context.Context, userId int64, chapter *playletchaptermod.PlayletChapter, resp *proto.PlayletChapterInfoResp, ) *srvdef.SrvErr { if chapter == nil || resp == nil { return srvdef.New(srvdef.ErrNoData, "剧集不存在", "剧集不存在") } resp.CostCoins = chapter.SingleCoins resp.CanWatch = false resp.NeedUnlock = false resp.NeedVip = false resp.VipValid = false resp.IsUnlocked = false if chapter.PayType == playletchaptermod.PayTypeFree { resp.CanWatch = true return nil } var user *usermod.User if userId > 0 { var err *srvdef.SrvErr user, err = findPlayletChapterAccessUser(ctx, userId) if err != nil { return err } resp.Balance = user.Recharge resp.VipValid = isUserVipValid(user) } switch chapter.PayType { case playletchaptermod.PayTypeCoin: if userId <= 0 { resp.NeedUnlock = true return nil } unlocked, err := playletchapterunlockmod.ExistsPlayletChapterUnlock(ctx, userId, chapter.Id) if err != nil { return err } resp.IsUnlocked = unlocked resp.CanWatch = unlocked resp.NeedUnlock = !unlocked case playletchaptermod.PayTypeVip: resp.CanWatch = resp.VipValid resp.NeedVip = !resp.VipValid default: log.Warn("未知短剧剧集支付方式", log.Any("playletId", chapter.PlayletId), log.Any("chapterId", chapter.Id), log.Any("payType", chapter.PayType)) resp.NeedUnlock = true } if !resp.CanWatch { resp.VideoUrl = "" } return nil } // findPlayletChapterForUnlock 查询解锁所需的剧集和所属短剧,限制短剧必须已上架。 func findPlayletChapterForUnlock(ctx context.Context, playletId int64, chapterId int64) (*playletchaptermod.PlayletChapter, *playletmod.Playlet, *srvdef.SrvErr) { chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: chapterId, playletchaptermod.FN.PlayletChapter.PlayletId: playletId, }, options.FindOne().SetProjection(playletChapterDetailProjection)) if err != nil { log.Error("查询待解锁短剧剧集失败", log.Any("playletId", playletId), log.Any("chapterId", chapterId), log.E(err)) return nil, nil, err } playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: chapter.PlayletId, }), options.FindOne().SetProjection(playletChapterSEOProjection)) if err != nil { log.Error("查询待解锁剧集所属短剧失败", log.Any("playletId", chapter.PlayletId), log.Any("chapterId", chapter.Id), log.E(err)) return nil, nil, err } if err = ensureOriginalChapterWatermarkReady(playlet, chapter); err != nil { return nil, nil, err } return chapter, playlet, nil } // findPlayletChapterAccessUser 查询播放权限判断需要的用户字段,避免读取完整用户文档。 func findPlayletChapterAccessUser(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) { user, err := usermod.FindOneUser(ctx, bson.M{ usermod.FN.User.Id: userId, }, options.FindOne().SetProjection(bson.D{ {Key: usermod.FN.User.Id, Value: 1}, {Key: usermod.FN.User.NickName, Value: 1}, {Key: usermod.FN.User.Recharge, Value: 1}, {Key: usermod.FN.User.CurrentVipExpire, Value: 1}, })) if err != nil || user == nil { return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "") } return user, nil } func isUserVipValid(user *usermod.User) bool { return user != nil && user.CurrentVipExpire.After(time.Now()) } func playletChapterUnlockOrderNo(userId int64, chapterId int64) string { return fmt.Sprintf("JJS-%d-%d-%d", userId, chapterId, time.Now().UnixNano()) } func playletChapterUnlockResp(chapter *playletchaptermod.PlayletChapter, balance int64, changeCoin int64) *proto.PlayletChapterUnlockResp { return &proto.PlayletChapterUnlockResp{ PlayletId: chapter.PlayletId, ChapterId: chapter.Id, Unlocked: true, CanWatch: true, Balance: balance, CostCoins: chapter.SingleCoins, ChangeCoin: changeCoin, } } // setPlayletLike 设置短剧点赞关系 func setPlayletLike(ctx context.Context, userId int64, playletId int64, enabled bool) (*playletmod.Playlet, bool, *srvdef.SrvErr) { // 操作接口必须登录,先查询短剧确保目标存在且已上架,再按点赞表真实变更决定是否更新点赞数。 if userId <= 0 { return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } playlet, err := findPlayletOperateTarget(ctx, userId, playletId) if err != nil { return nil, false, err } filter := bson.M{ playletlikemod.FN.PlayletLike.UserId: userId, playletlikemod.FN.PlayletLike.PlayletId: playletId, } changed := false if enabled { inserted, err := playletlikemod.InsertOnePlayletLike(ctx, &playletlikemod.PlayletLike{ UserId: userId, PlayletId: playletId, }) if err != nil { log.Error("新增短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) return nil, false, err } changed = inserted } else { deleted, err := playletlikemod.DeleteOnePlayletLike(ctx, filter) if err != nil { log.Error("删除短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) return nil, false, err } changed = deleted > 0 } if changed { delta := int64(1) if !enabled { delta = -1 } heatDelta := playletHeatLikeInc if !enabled { heatDelta = -playletHeatLikeInc } if err := incrementPlayletOperateCounter(ctx, playletId, playletmod.FN.Playlet.CountLike, delta, heatDelta); err != nil { rollbackPlayletLike(ctx, userId, playletId, enabled) return nil, false, err } // 真实点赞数和热度值已变更,异步同步 ES,保证搜索和榜单数据最终一致 syncPlayletToESAsync(playletId, "短剧点赞状态同步ES失败") } return playlet, changed, nil } // setPlayletCollect 设置短剧收藏关系 func setPlayletCollect(ctx context.Context, userId int64, playletId int64, enabled bool) (*playletmod.Playlet, bool, *srvdef.SrvErr) { // 操作接口必须登录,先查询短剧确保目标存在且已上架,再按收藏表真实变更决定是否更新收藏数。 if userId <= 0 { return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } playlet, err := findPlayletOperateTarget(ctx, userId, playletId) if err != nil { return nil, false, err } filter := bson.M{ playletcollectmod.FN.PlayletCollect.UserId: userId, playletcollectmod.FN.PlayletCollect.PlayletId: playletId, } changed := false if enabled { inserted, err := playletcollectmod.InsertOnePlayletCollect(ctx, &playletcollectmod.PlayletCollect{ UserId: userId, PlayletId: playletId, }) if err != nil { log.Error("新增短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) return nil, false, err } changed = inserted } else { deleted, err := playletcollectmod.DeleteOnePlayletCollect(ctx, filter) if err != nil { log.Error("删除短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) return nil, false, err } changed = deleted > 0 } if changed { delta := int64(1) if !enabled { delta = -1 } heatDelta := playletHeatCollectInc if !enabled { heatDelta = -playletHeatCollectInc } if err := incrementPlayletOperateCounter(ctx, playletId, playletmod.FN.Playlet.CountCollect, delta, heatDelta); err != nil { rollbackPlayletCollect(ctx, userId, playletId, enabled) return nil, false, err } // 真实收藏数和热度值已变更,异步同步 ES,保证追剧榜和搜索数据最终一致 syncPlayletToESAsync(playletId, "短剧收藏状态同步ES失败") } return playlet, changed, nil } // setPlayletChapterLike 设置短剧剧集点赞关系 func setPlayletChapterLike(ctx context.Context, userId int64, chapterId int64, enabled bool) (*playletchaptermod.PlayletChapter, bool, *srvdef.SrvErr) { // 剧集点赞必须登录,先校验剧集存在且所属短剧已上架,再按点赞关系真实变化更新 playlet_chapter.countLike。 if userId <= 0 { return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } chapter, err := findPlayletChapterOperateTarget(ctx, userId, chapterId) if err != nil { return nil, false, err } filter := bson.M{ playletchapterlikemod.FN.PlayletChapterLike.UserId: userId, playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId, } changed := false if enabled { inserted, err := playletchapterlikemod.InsertOnePlayletChapterLike(ctx, &playletchapterlikemod.PlayletChapterLike{ UserId: userId, PlayletId: chapter.PlayletId, ChapterId: chapterId, }) if err != nil { log.Error("新增短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) return nil, false, err } changed = inserted } else { deleted, err := playletchapterlikemod.DeleteOnePlayletChapterLike(ctx, filter) if err != nil { log.Error("删除短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) return nil, false, err } changed = deleted > 0 } if changed { delta := int64(1) if !enabled { delta = -1 } if err := incrementPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountLike, delta); err != nil { rollbackPlayletChapterLike(ctx, userId, chapter.PlayletId, chapterId, enabled) return nil, false, err } heatDelta := playletHeatLikeInc if !enabled { heatDelta = -playletHeatLikeInc } if err := incrementPlayletHeatValue(ctx, chapter.PlayletId, heatDelta); err != nil { log.Error("更新短剧剧集点赞对应短剧热度失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.Any("增量", heatDelta), log.E(err)) rollbackPlayletChapterLike(ctx, userId, chapter.PlayletId, chapterId, enabled) rollbackPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountLike, delta) return nil, false, err } } return chapter, changed, nil } // setPlayletChapterCollect 设置短剧剧集收藏关系 func setPlayletChapterCollect(ctx context.Context, userId int64, chapterId int64, enabled bool) (*playletchaptermod.PlayletChapter, bool, *srvdef.SrvErr) { // 剧集收藏必须登录,先校验剧集存在且所属短剧已上架,再按收藏关系真实变化更新 playlet_chapter.countCollect。 if userId <= 0 { return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } chapter, err := findPlayletChapterOperateTarget(ctx, userId, chapterId) if err != nil { return nil, false, err } filter := bson.M{ playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId, playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId, } changed := false if enabled { inserted, err := playletchaptercollectmod.InsertOnePlayletChapterCollect(ctx, &playletchaptercollectmod.PlayletChapterCollect{ UserId: userId, PlayletId: chapter.PlayletId, ChapterId: chapterId, }) if err != nil { log.Error("新增短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) return nil, false, err } changed = inserted } else { deleted, err := playletchaptercollectmod.DeleteOnePlayletChapterCollect(ctx, filter) if err != nil { log.Error("删除短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) return nil, false, err } changed = deleted > 0 } if changed { delta := int64(1) if !enabled { delta = -1 } if err := incrementPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountCollect, delta); err != nil { rollbackPlayletChapterCollect(ctx, userId, chapter.PlayletId, chapterId, enabled) return nil, false, err } heatDelta := playletHeatCollectInc if !enabled { heatDelta = -playletHeatCollectInc } if err := incrementPlayletHeatValue(ctx, chapter.PlayletId, heatDelta); err != nil { log.Error("更新短剧剧集收藏对应短剧热度失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.Any("增量", heatDelta), log.E(err)) rollbackPlayletChapterCollect(ctx, userId, chapter.PlayletId, chapterId, enabled) rollbackPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountCollect, delta) return nil, false, err } } return chapter, changed, nil } // findPlayletOperateTarget 查询短剧操作目标 func findPlayletOperateTarget(ctx context.Context, userId int64, playletId int64) (*playletmod.Playlet, *srvdef.SrvErr) { // 操作前只读取计数字段,减少Mongo返回体;状态过滤保证下架短剧不能被点赞或收藏。 playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: playletId, }), options.FindOne().SetProjection(playletOperateProjection)) if err != nil { log.Error("查询短剧操作目标失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) return nil, err } return playlet, nil } // findPlayletChapterOperateTarget 查询短剧剧集操作目标 func findPlayletChapterOperateTarget(ctx context.Context, userId int64, chapterId int64) (*playletchaptermod.PlayletChapter, *srvdef.SrvErr) { // 先用章节ID读取最小字段,再校验所属短剧已上架,避免用户操作已下架短剧的剧集。 chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: chapterId, }, options.FindOne().SetProjection(playletChapterOperateProjection)) if err != nil { log.Error("查询短剧剧集操作目标失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err)) return nil, err } if _, err = playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: chapter.PlayletId, }), options.FindOne().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, })); err != nil { log.Error("查询短剧剧集所属短剧失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) return nil, err } return chapter, nil } // rollbackPlayletLike 回滚短剧点赞关系 func rollbackPlayletLike(ctx context.Context, userId int64, playletId int64, enabled bool) { // 点赞表已经变化但短剧点赞数更新失败时做补偿,尽量保持点赞关系和点赞数一致。 filter := bson.M{ playletlikemod.FN.PlayletLike.UserId: userId, playletlikemod.FN.PlayletLike.PlayletId: playletId, } if enabled { if _, err := playletlikemod.DeleteOnePlayletLike(ctx, filter); err != nil { log.Error("回滚短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) } return } if _, err := playletlikemod.InsertOnePlayletLike(ctx, &playletlikemod.PlayletLike{ UserId: userId, PlayletId: playletId, }); err != nil { log.Error("恢复短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) } } // rollbackPlayletCollect 回滚短剧收藏关系 func rollbackPlayletCollect(ctx context.Context, userId int64, playletId int64, enabled bool) { // 收藏表已经变化但短剧收藏数更新失败时做补偿,尽量保持收藏关系和收藏数一致。 filter := bson.M{ playletcollectmod.FN.PlayletCollect.UserId: userId, playletcollectmod.FN.PlayletCollect.PlayletId: playletId, } if enabled { if _, err := playletcollectmod.DeleteOnePlayletCollect(ctx, filter); err != nil { log.Error("回滚短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) } return } if _, err := playletcollectmod.InsertOnePlayletCollect(ctx, &playletcollectmod.PlayletCollect{ UserId: userId, PlayletId: playletId, }); err != nil { log.Error("恢复短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) } } // rollbackPlayletChapterLike 回滚短剧剧集点赞关系 func rollbackPlayletChapterLike(ctx context.Context, userId int64, playletId int64, chapterId int64, enabled bool) { // 剧集点赞关系已经变化但剧集点赞数更新失败时做补偿,尽量保持点赞关系和点赞数一致。 filter := bson.M{ playletchapterlikemod.FN.PlayletChapterLike.UserId: userId, playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId, } if enabled { if _, err := playletchapterlikemod.DeleteOnePlayletChapterLike(ctx, filter); err != nil { log.Error("回滚短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) } return } if _, err := playletchapterlikemod.InsertOnePlayletChapterLike(ctx, &playletchapterlikemod.PlayletChapterLike{ UserId: userId, PlayletId: playletId, ChapterId: chapterId, }); err != nil { log.Error("恢复短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) } } // rollbackPlayletChapterCollect 回滚短剧剧集收藏关系 func rollbackPlayletChapterCollect(ctx context.Context, userId int64, playletId int64, chapterId int64, enabled bool) { // 剧集收藏关系已经变化但剧集收藏数更新失败时做补偿,尽量保持收藏关系和收藏数一致。 filter := bson.M{ playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId, playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId, } if enabled { if _, err := playletchaptercollectmod.DeleteOnePlayletChapterCollect(ctx, filter); err != nil { log.Error("回滚短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) } return } if _, err := playletchaptercollectmod.InsertOnePlayletChapterCollect(ctx, &playletchaptercollectmod.PlayletChapterCollect{ UserId: userId, PlayletId: playletId, ChapterId: chapterId, }); err != nil { log.Error("恢复短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) } } // incrementPlayletOperateCounter 增减短剧点赞或收藏计数 func incrementPlayletOperateCounter(ctx context.Context, playletId int64, countField string, delta int64, heatDelta int64) *srvdef.SrvErr { // 取消操作时增加 countField > 0 条件,避免历史脏数据或并发场景把计数减成负数。 filter := bson.M{ playletmod.FN.Playlet.Id: playletId, } if delta < 0 { filter[countField] = bson.M{"$gt": 0} } incDoc := bson.M{ countField: delta, } if heatDelta != 0 { incDoc[playletmod.FN.Playlet.HeatValue] = heatDelta } err := playletmod.UpdateOnePlaylet(ctx, filter, bson.M{ "$inc": incDoc, }) if err != nil { if delta < 0 && err.Code == srvdef.ErrNoData { log.Warn("短剧操作计数已为0,跳过扣减", log.Any("短剧ID", playletId), log.Any("计数字段", countField)) return nil } log.Error("更新短剧操作计数失败", log.Any("短剧ID", playletId), log.Any("计数字段", countField), log.Any("增量", delta), log.E(err)) return err } return nil } // incrementPlayletChapterOperateCounter 增减短剧剧集点赞或收藏计数 func incrementPlayletChapterOperateCounter(ctx context.Context, chapterId int64, countField string, delta int64) *srvdef.SrvErr { // 取消操作时增加 countField > 0 条件,避免历史脏数据或并发场景把计数减成负数。 filter := bson.M{ playletchaptermod.FN.PlayletChapter.Id: chapterId, } if delta < 0 { filter[countField] = bson.M{"$gt": 0} } err := playletchaptermod.UpdateOnePlayletChapter(ctx, filter, bson.M{ "$inc": bson.M{ countField: delta, }, }) if err != nil { if delta < 0 && err.Code == srvdef.ErrNoData { log.Warn("短剧剧集操作计数已为0,跳过扣减", log.Any("剧集ID", chapterId), log.Any("计数字段", countField)) return nil } log.Error("更新短剧剧集操作计数失败", log.Any("剧集ID", chapterId), log.Any("计数字段", countField), log.Any("增量", delta), log.E(err)) return err } return nil } func rollbackPlayletChapterOperateCounter(ctx context.Context, chapterId int64, countField string, delta int64) { rollbackDelta := -delta if err := incrementPlayletChapterOperateCounter(ctx, chapterId, countField, rollbackDelta); err != nil { log.Error("回滚短剧剧集操作计数失败", log.Any("剧集ID", chapterId), log.Any("计数字段", countField), log.Any("增量", rollbackDelta), log.E(err)) } } // applyLocalCounter 根据本次关系变更结果计算响应计数 func applyLocalCounter(current int64, enabled bool, changed bool) int64 { // 响应计数基于操作前短剧计数和本次真实变更计算,避免为返回值再查一次短剧表。 if !changed { return current } if enabled { return current + 1 } if current <= 0 { return 0 } return current - 1 } func displayPlayletCountLike(countLike int64, virtualCountLike int64) int64 { return countLike + virtualCountLike } // syncPlayletToESAsync 异步同步短剧到ES func syncPlayletToESAsync(playletId int64, failMsg string) { if playletId <= 0 { return } go func() { // 点赞、收藏入口只允许操作已上架短剧 if err := srv_search.SyncPlayletStatusToES(context.Background(), []int64{playletId}, playletmod.StatusUp); err != nil { log.Error(failMsg, log.Any("短剧ID", playletId), log.E(err)) } }() } // operateStatusByFlag 转换操作状态响应值 func operateStatusByFlag(enabled bool) int64 { if enabled { return operateStatusYes } return operateStatusNo } // Detail 根据ID查询app端短剧详情 func Detail(ctx context.Context, userId int64, req *proto.PlayletInfoReq) (*proto.PlayletInfoResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") } playletID, err := resolvePlayletID(ctx, req.Id, req.Slug, true) if err != nil { return nil, err } req.Id = playletID item, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: req.Id, }), options.FindOne().SetProjection(playletHomeProjection)) if err != nil { log.Error("查询短剧详情失败", log.Any("短剧ID", req.Id), log.E(err)) return nil, err } if err = incrementPlayletBrowse(ctx, item.Id); err != nil { log.Error("增加短剧观看量失败", log.Any("短剧ID", item.Id), log.E(err)) return nil, err } item.CountBrowse++ item.HeatValue += playletHeatClickInc themeNameMap, err := buildHomeThemeNameMap(ctx, playletmod.PlayletList{item}) if err != nil { log.Error("查询短剧详情题材名称失败", log.Any("短剧ID", item.Id), log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, playletmod.PlayletList{item}) if err != nil { log.Error("查询短剧详情标签名称失败", log.Any("短剧ID", item.Id), log.E(err)) return nil, err } commonList := playletsToHomeCommonData(ctx, playletmod.PlayletList{item}, themeNameMap, tagNameMap) if len(commonList) == 0 || commonList[0] == nil { return nil, srvdef.New(srvdef.ErrNoData, "", "") } chapterInfo, err := findPlayletChapters(ctx, item.Id, userId, item.IsOriginal) if err != nil { return nil, err } isLike, isCollect, err := findPlayletUserOperateStatus(ctx, userId, item.Id) if err != nil { return nil, err } resp := &proto.PlayletInfoResp{ CommonData: *commonList[0], IsLike: isLike, IsCollect: isCollect, PlayletChapterInfo: chapterInfo, SeoInfo: buildPlayletDetailSeoInfo(item, themeNameMap, tagNameMap), Advitise: configser.GetAdvitise(), } if resp.Preview != "" { resp.Preview = videoser.GetM3U8AuthParam( item.Id, resp.Preview, videoser.H5M3U8RequestIP(ctx), "", userId, ) } return resp, nil } // findPlayletUserOperateStatus 查询当前用户短剧点赞和收藏状态 func findPlayletUserOperateStatus(ctx context.Context, userId int64, playletId int64) (int64, int64, *srvdef.SrvErr) { // 游客或无有效token时没有用户ID,直接返回未点赞、未收藏,保证详情接口仍可匿名访问。 if userId <= 0 { return operateStatusNo, operateStatusNo, nil } likeExists, err := playletlikemod.ExistsPlayletLike(ctx, bson.M{ playletlikemod.FN.PlayletLike.UserId: userId, playletlikemod.FN.PlayletLike.PlayletId: playletId, }) if err != nil { log.Error("查询短剧用户点赞状态失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) return operateStatusNo, operateStatusNo, err } collectExists, err := playletcollectmod.ExistsPlayletCollect(ctx, bson.M{ playletcollectmod.FN.PlayletCollect.UserId: userId, playletcollectmod.FN.PlayletCollect.PlayletId: playletId, }) if err != nil { log.Error("查询短剧用户收藏状态失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) return operateStatusNo, operateStatusNo, err } isLike := operateStatusByFlag(likeExists) isCollect := operateStatusByFlag(collectExists) return isLike, isCollect, nil } // CollectList 分页查询当前用户的短剧收藏列表 func CollectList(ctx context.Context, userId int64, req *proto.PlayletCollectListReq) (*proto.PlayletCollectListResp, *srvdef.SrvErr) { if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) filter := bson.M{ playletcollectmod.FN.PlayletCollect.UserId: userId, } if err := applyCollectWatchStatusFilter(ctx, userId, req.WatchStatus, filter); err != nil { return nil, err } totalCount, err := playletcollectmod.CountPlayletCollect(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletCollectListResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.PlayletCollectData, 0), } if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } collectList, err := playletcollectmod.FindManyPlayletCollect(ctx, filter, options.Find(). SetProjection(bson.D{ {Key: playletcollectmod.FN.PlayletCollect.Id, Value: 1}, {Key: playletcollectmod.FN.PlayletCollect.PlayletId, Value: 1}, {Key: playletcollectmod.FN.PlayletCollect.CreatedAt, Value: 1}, }). SetSort(bson.D{ {Key: playletcollectmod.FN.PlayletCollect.CreatedAt, Value: -1}, {Key: playletcollectmod.FN.PlayletCollect.Id, Value: -1}, }). SetSkip(skip). SetLimit(pageSize)) if err != nil { return nil, err } if len(collectList) == 0 { return resp, nil } playletIds := collectPlayletIdsFromCollects(collectList) playletMap, err := findCollectPlayletMap(ctx, playletIds) if err != nil { return nil, err } progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds) if err != nil { return nil, err } playletList := playletListFromMapByCollects(collectList, playletMap) themeNameMap, err := buildHomeThemeNameMap(ctx, playletList) if err != nil { log.Error("查询我的收藏题材名称失败", log.Any("用户ID", userId), log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, playletList) if err != nil { log.Error("查询我的收藏标签名称失败", log.Any("用户ID", userId), log.E(err)) return nil, err } resp.List = buildPlayletCollectData(ctx, collectList, playletMap, progressMap, themeNameMap, tagNameMap) return resp, nil } func applyCollectWatchStatusFilter(ctx context.Context, userId int64, watchStatus int64, filter bson.M) *srvdef.SrvErr { if watchStatus == 0 { return nil } if watchStatus != 1 && watchStatus != 2 { return srvdef.New(srvdef.ErrParamError, "观看状态错误", "观看状态错误") } playletIds, err := findCollectWatchStatusPlayletIds(ctx, userId, watchStatus == 1) if err != nil { return err } filter[playletcollectmod.FN.PlayletCollect.PlayletId] = bson.M{"$in": playletIds} return nil } func findCollectWatchStatusPlayletIds(ctx context.Context, userId int64, isChasing bool) ([]int64, *srvdef.SrvErr) { list, err := playletwatchprogressmod.FindManyPlayletWatchProgress(ctx, bson.M{ playletwatchprogressmod.FN.PlayletWatchProgress.UserId: userId, playletwatchprogressmod.FN.PlayletWatchProgress.IsChasing: isChasing, }, options.Find().SetProjection(bson.D{ {Key: playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId, Value: 1}, })) if err != nil { log.Error("查询收藏列表观看状态短剧ID失败", log.Any("用户ID", userId), log.Any("是否追剧中", isChasing), log.E(err)) return nil, err } playletIds := make([]int64, 0, len(list)) seen := make(map[int64]struct{}, len(list)) for _, item := range list { if item == nil || item.PlayletId <= 0 { continue } if _, ok := seen[item.PlayletId]; ok { continue } seen[item.PlayletId] = struct{}{} playletIds = append(playletIds, item.PlayletId) } return playletIds, nil } // BoughtList 分页查询当前用户已购短剧列表 func BoughtList(ctx context.Context, userId int64, req *proto.PlayletBoughtListReq) (*proto.PlayletBoughtListResp, *srvdef.SrvErr) { if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) totalCount, err := countBoughtPlaylets(ctx, userId) if err != nil { return nil, err } resp := &proto.PlayletBoughtListResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.PlayletBoughtData, 0), } if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } rows, err := findBoughtPlayletRows(ctx, userId, skip, pageSize) if err != nil { return nil, err } if len(rows) == 0 { return resp, nil } playletIds := collectPlayletIdsFromBoughtRows(rows) playletMap, err := findCollectPlayletMap(ctx, playletIds) if err != nil { return nil, err } progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds) if err != nil { return nil, err } playletList := playletListFromBoughtRows(rows, playletMap) themeNameMap, err := buildHomeThemeNameMap(ctx, playletList) if err != nil { log.Error("查询我的已购短剧题材名称失败", log.Any("用户ID", userId), log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, playletList) if err != nil { log.Error("查询我的已购短剧标签名称失败", log.Any("用户ID", userId), log.E(err)) return nil, err } resp.List = buildPlayletBoughtData(ctx, rows, playletMap, progressMap, themeNameMap, tagNameMap) return resp, nil } // countBoughtPlaylets 统计当前用户已购短剧数量 func countBoughtPlaylets(ctx context.Context, userId int64) (int64, *srvdef.SrvErr) { rows := make([]playletBoughtCountRow, 0, 1) pipeline := []bson.M{ { "$match": bson.M{ playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId, playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: bson.M{"$gt": 0}, }, }, { "$group": bson.M{ "_id": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId, }, }, { "$count": "count", }, } if err := playletchapterunlockmod.AggregatePlayletChapterUnlock(ctx, &rows, pipeline); err != nil { return 0, err } if len(rows) == 0 { return 0, nil } return rows[0].Count, nil } // findBoughtPlayletRows 聚合查询当前用户已购短剧分页数据 func findBoughtPlayletRows(ctx context.Context, userId int64, skip int64, limit int64) ([]playletBoughtAggRow, *srvdef.SrvErr) { rows := make([]playletBoughtAggRow, 0) pipeline := []bson.M{ { "$match": bson.M{ playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId, playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: bson.M{"$gt": 0}, }, }, { "$group": bson.M{ "_id": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId, "boughtChapterCount": bson.M{"$sum": 1}, "costCoins": bson.M{"$sum": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.Coins}, "boughtAt": bson.M{"$max": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.CreatedAt}, "latestUnlockId": bson.M{"$max": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.Id}, }, }, { "$sort": bson.D{ {Key: "boughtAt", Value: -1}, {Key: "latestUnlockId", Value: -1}, {Key: "_id", Value: -1}, }, }, { "$skip": skip, }, { "$limit": limit, }, } if err := playletchapterunlockmod.AggregatePlayletChapterUnlock(ctx, &rows, pipeline); err != nil { return nil, err } return rows, nil } // collectPlayletIdsFromBoughtRows 从已购短剧聚合结果中提取短剧ID集合 func collectPlayletIdsFromBoughtRows(rows []playletBoughtAggRow) []int64 { result := make([]int64, 0, len(rows)) seen := make(map[int64]struct{}, len(rows)) for _, row := range rows { if row.PlayletId <= 0 { continue } if _, ok := seen[row.PlayletId]; ok { continue } seen[row.PlayletId] = struct{}{} result = append(result, row.PlayletId) } return result } // playletListFromBoughtRows 按已购短剧聚合顺序返回短剧集合 func playletListFromBoughtRows(rows []playletBoughtAggRow, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList { result := make(playletmod.PlayletList, 0, len(rows)) for _, row := range rows { item := playletMap[row.PlayletId] if item == nil { continue } result = append(result, item) } return result } // ChapterCollectList 分页查询当前用户收藏的短剧剧集列表 func ChapterCollectList(ctx context.Context, userId int64, req *proto.PlayletChapterCollectListReq) (*proto.PlayletChapterCollectListResp, *srvdef.SrvErr) { if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) filter := bson.M{ playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId, } totalCount, err := playletchaptercollectmod.CountPlayletChapterCollect(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletChapterCollectListResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.PlayletChapterCollectData, 0), } if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } collectList, err := playletchaptercollectmod.FindManyPlayletChapterCollect(ctx, filter, options.Find(). SetProjection(bson.D{ {Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: 1}, {Key: playletchaptercollectmod.FN.PlayletChapterCollect.PlayletId, Value: 1}, {Key: playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId, Value: 1}, {Key: playletchaptercollectmod.FN.PlayletChapterCollect.CreatedAt, Value: 1}, }). SetSort(bson.D{ {Key: playletchaptercollectmod.FN.PlayletChapterCollect.CreatedAt, Value: -1}, {Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: -1}, }). SetSkip(skip). SetLimit(pageSize)) if err != nil { return nil, err } if len(collectList) == 0 { return resp, nil } chapterIds := collectChapterIdsFromChapterCollects(collectList) chapterMap, err := findChapterCollectChapterMap(ctx, chapterIds) if err != nil { return nil, err } playletIds := collectPlayletIdsFromChapterCollects(collectList, chapterMap) playletMap, err := findChapterCollectPlayletMap(ctx, playletIds) if err != nil { return nil, err } resp.List = buildPlayletChapterCollectData(ctx, collectList, chapterMap, playletMap) return resp, nil } // HistoryList 分页查询当前用户的短剧观看历史 func HistoryList(ctx context.Context, userId int64, req *proto.PlayletHistoryListReq) (*proto.PlayletHistoryListResp, *srvdef.SrvErr) { if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } pageNum, pageSize := normalizePlayletHistoryPage(req.PageNum, req.PageSize) filter := bson.M{ playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId, } totalCount, err := playletwatchhistorymod.CountPlayletWatchHistory(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletHistoryListResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.PlayletHistoryData, 0), } if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } historyList, err := playletwatchhistorymod.FindManyPlayletWatchHistory(ctx, filter, options.Find(). SetProjection(bson.D{ {Key: playletwatchhistorymod.FN.PlayletWatchHistory.Id, Value: 1}, {Key: playletwatchhistorymod.FN.PlayletWatchHistory.PlayletId, Value: 1}, {Key: playletwatchhistorymod.FN.PlayletWatchHistory.LastChapterId, Value: 1}, {Key: playletwatchhistorymod.FN.PlayletWatchHistory.LastEpisode, Value: 1}, {Key: playletwatchhistorymod.FN.PlayletWatchHistory.UpdatedAt, Value: 1}, }). SetSort(bson.D{ {Key: playletwatchhistorymod.FN.PlayletWatchHistory.UpdatedAt, Value: -1}, {Key: playletwatchhistorymod.FN.PlayletWatchHistory.Id, Value: -1}, }). SetSkip(skip). SetLimit(pageSize)) if err != nil { return nil, err } if len(historyList) == 0 { return resp, nil } playletIds := collectPlayletIdsFromHistories(historyList) playletMap, err := findHistoryPlayletMap(ctx, playletIds) if err != nil { return nil, err } progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds) if err != nil { return nil, err } playletList := playletListFromMapByHistories(historyList, playletMap) themeNameMap, err := buildHomeThemeNameMap(ctx, playletList) if err != nil { log.Error("查询观看历史题材名称失败", log.Any("用户ID", userId), log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, playletList) if err != nil { log.Error("查询观看历史标签名称失败", log.Any("用户ID", userId), log.E(err)) return nil, err } resp.List = buildPlayletHistoryData(ctx, historyList, playletMap, progressMap, themeNameMap, tagNameMap) return resp, nil } // DeleteHistory 删除当前用户指定短剧观看历史,不影响观看进度和收藏列表 func DeleteHistory(ctx context.Context, userId int64, req *proto.PlayletHistoryDeleteReq) (*proto.PlayletHistoryDeleteResp, *srvdef.SrvErr) { if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } if req == nil || req.PlayletId <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") } deleted, err := playletwatchhistorymod.DeleteManyPlayletWatchHistory(ctx, bson.M{ playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId, playletwatchhistorymod.FN.PlayletWatchHistory.PlayletId: req.PlayletId, }) if err != nil { log.Error("删除单条短剧观看历史失败", log.Any("用户ID", userId), log.Any("短剧ID", req.PlayletId), log.E(err)) return nil, err } return &proto.PlayletHistoryDeleteResp{DeletedCount: deleted}, nil } // ClearHistory 清空当前用户的短剧观看历史,不影响观看进度和收藏列表。 func ClearHistory(ctx context.Context, userId int64) (*proto.PlayletHistoryClearResp, *srvdef.SrvErr) { if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") } deleted, err := playletwatchhistorymod.DeleteManyPlayletWatchHistory(ctx, bson.M{ playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId, }) if err != nil { return nil, err } return &proto.PlayletHistoryClearResp{DeletedCount: deleted}, nil } // collectPlayletIdsFromCollects 按收藏列表顺序收集短剧ID并去重,避免批量查询重复ID。 func collectPlayletIdsFromCollects(list playletcollectmod.PlayletCollectList) []int64 { result := make([]int64, 0, len(list)) seen := make(map[int64]struct{}, len(list)) for _, item := range list { if item == nil || item.PlayletId <= 0 { continue } if _, ok := seen[item.PlayletId]; ok { continue } seen[item.PlayletId] = struct{}{} result = append(result, item.PlayletId) } return result } // collectPlayletIdsFromHistories 按观看历史顺序收集短剧ID并去重,避免批量查询重复ID。 func collectPlayletIdsFromHistories(list playletwatchhistorymod.PlayletWatchHistoryList) []int64 { result := make([]int64, 0, len(list)) seen := make(map[int64]struct{}, len(list)) for _, item := range list { if item == nil || item.PlayletId <= 0 { continue } if _, ok := seen[item.PlayletId]; ok { continue } seen[item.PlayletId] = struct{}{} result = append(result, item.PlayletId) } return result } // collectChapterIdsFromChapterCollects 按剧集收藏顺序收集剧集ID并去重。 func collectChapterIdsFromChapterCollects(list playletchaptercollectmod.PlayletChapterCollectList) []int64 { result := make([]int64, 0, len(list)) seen := make(map[int64]struct{}, len(list)) for _, item := range list { if item == nil || item.ChapterId <= 0 { continue } if _, ok := seen[item.ChapterId]; ok { continue } seen[item.ChapterId] = struct{}{} result = append(result, item.ChapterId) } return result } // collectPlayletIdsFromChapterCollects 从收藏关系和剧集数据中收集短剧ID,兼容收藏关系里playletId缺失的历史数据。 func collectPlayletIdsFromChapterCollects( list playletchaptercollectmod.PlayletChapterCollectList, chapterMap map[int64]*playletchaptermod.PlayletChapter, ) []int64 { result := make([]int64, 0, len(list)) seen := make(map[int64]struct{}, len(list)) for _, item := range list { if item == nil { continue } playletId := item.PlayletId if playletId <= 0 { if chapter := chapterMap[item.ChapterId]; chapter != nil { playletId = chapter.PlayletId } } if playletId <= 0 { continue } if _, ok := seen[playletId]; ok { continue } seen[playletId] = struct{}{} result = append(result, playletId) } return result } // findCollectPlayletMap 批量查询收藏关联的短剧数据,返回map便于按收藏时间顺序组装。 func findCollectPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) { result := make(map[int64]*playletmod.Playlet, len(playletIds)) if len(playletIds) == 0 { return result, nil } list, err := playletmod.FindManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: bson.M{"$in": playletIds}, }, options.Find().SetProjection(playletCollectListProjection)) if err != nil { log.Error("批量查询我的收藏短剧失败", log.Any("短剧ID集合", playletIds), log.E(err)) return nil, err } for _, item := range list { if item == nil { continue } result[item.Id] = item } return result, nil } // findChapterCollectPlayletMap 批量查询剧集收藏关联的短剧数据。 func findChapterCollectPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) { result := make(map[int64]*playletmod.Playlet, len(playletIds)) if len(playletIds) == 0 { return result, nil } list, err := playletmod.FindManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: bson.M{"$in": playletIds}, }, options.Find().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.Title, Value: 1}, {Key: playletmod.FN.Playlet.Cover, Value: 1}, {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, {Key: playletmod.FN.Playlet.UpdateStatus, Value: 1}, })) if err != nil { log.Error("批量查询我的剧集收藏短剧失败", log.Any("短剧ID集合", playletIds), log.E(err)) return nil, err } for _, item := range list { if item == nil { continue } result[item.Id] = item } return result, nil } // findChapterCollectChapterMap 批量查询收藏关联的剧集数据。 func findChapterCollectChapterMap(ctx context.Context, chapterIds []int64) (map[int64]*playletchaptermod.PlayletChapter, *srvdef.SrvErr) { result := make(map[int64]*playletchaptermod.PlayletChapter, len(chapterIds)) if len(chapterIds) == 0 { return result, nil } list, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": chapterIds}, }, options.Find().SetProjection(bson.D{ {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CountBrowse, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1}, })) if err != nil { log.Error("批量查询我的剧集收藏剧集失败", log.Any("剧集ID集合", chapterIds), log.E(err)) return nil, err } for _, item := range list { if item == nil { continue } result[item.Id] = item } return result, nil } // findHistoryPlayletMap 批量查询观看历史关联的短剧数据,返回map便于按观看时间顺序组装。 func findHistoryPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) { result := make(map[int64]*playletmod.Playlet, len(playletIds)) if len(playletIds) == 0 { return result, nil } list, err := playletmod.FindManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: bson.M{"$in": playletIds}, }, options.Find().SetProjection(playletHomeProjection)) if err != nil { log.Error("批量查询观看历史短剧失败", log.Any("短剧ID集合", playletIds), log.E(err)) return nil, err } for _, item := range list { if item == nil { continue } result[item.Id] = item } return result, nil } // findCollectWatchProgressMap 批量查询当前用户收藏短剧的观看进度,避免收藏列表逐条查询。 func findCollectWatchProgressMap(ctx context.Context, userId int64, playletIds []int64) (map[int64]*playletwatchprogressmod.PlayletWatchProgress, *srvdef.SrvErr) { result := make(map[int64]*playletwatchprogressmod.PlayletWatchProgress, len(playletIds)) if userId <= 0 || len(playletIds) == 0 { return result, nil } list, err := playletwatchprogressmod.FindManyPlayletWatchProgress(ctx, bson.M{ playletwatchprogressmod.FN.PlayletWatchProgress.UserId: userId, playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId: bson.M{"$in": playletIds}, }, options.Find().SetProjection(bson.D{ {Key: playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId, Value: 1}, {Key: playletwatchprogressmod.FN.PlayletWatchProgress.LastChapterId, Value: 1}, {Key: playletwatchprogressmod.FN.PlayletWatchProgress.LastEpisode, Value: 1}, {Key: playletwatchprogressmod.FN.PlayletWatchProgress.WatchedEpisodes, Value: 1}, {Key: playletwatchprogressmod.FN.PlayletWatchProgress.WatchedCount, Value: 1}, })) if err != nil { log.Error("批量查询我的收藏观看进度失败", log.Any("用户ID", userId), log.Any("短剧ID集合", playletIds), log.E(err)) return nil, err } for _, item := range list { if item == nil { continue } result[item.PlayletId] = item } return result, nil } // playletListFromMapByCollects 按收藏列表顺序构造短剧列表,用于批量查询题材和标签名称。 func playletListFromMapByCollects(collectList playletcollectmod.PlayletCollectList, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList { result := make(playletmod.PlayletList, 0, len(collectList)) for _, collect := range collectList { if collect == nil { continue } if item := playletMap[collect.PlayletId]; item != nil { result = append(result, item) } } return result } // playletListFromMapByHistories 按观看历史顺序构造短剧列表,用于批量查询题材和标签名称。 func playletListFromMapByHistories(historyList playletwatchhistorymod.PlayletWatchHistoryList, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList { result := make(playletmod.PlayletList, 0, len(historyList)) for _, history := range historyList { if history == nil { continue } if item := playletMap[history.PlayletId]; item != nil { result = append(result, item) } } return result } // buildPlayletCollectData 组装我的收藏响应,保持playlet_collect的倒序分页顺序。 func buildPlayletCollectData( ctx context.Context, collectList playletcollectmod.PlayletCollectList, playletMap map[int64]*playletmod.Playlet, progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress, themeNameMap map[int64]string, tagNameMap map[int64]string, ) []proto.PlayletCollectData { result := make([]proto.PlayletCollectData, 0, len(collectList)) for _, collect := range collectList { if collect == nil { continue } item := playletMap[collect.PlayletId] if item == nil { continue } progress := progressMap[item.Id] watchedCount := collectWatchedCount(progress, item.TotalEpisode) result = append(result, proto.PlayletCollectData{ Id: item.Id, Title: item.Title, PlayName: item.PlayName, Slug: item.Slug, Cover: withImageDomain(ctx, item.DisplayCover()), ThemeIds: item.ThemeIds, ThemeNames: namesByIds(item.ThemeIds, themeNameMap), ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), TagIds: item.TagIds, TagNames: namesByIds(item.TagIds, tagNameMap), TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), TotalEpisode: item.TotalEpisode, UpdateStatus: item.UpdateStatus, CountBrowse: item.CountBrowse, HeatValue: item.HeatValue, CountCollect: item.CountCollect, IsCollect: operateStatusYes, CollectedAt: collect.CreatedAt, WatchedCount: watchedCount, UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount), LastChapterId: collectLastChapterId(progress), LastEpisode: collectLastEpisode(progress), SeoInfo: item.SeoInfo, }) } return result } // buildPlayletBoughtData 组装我的已购短剧响应,保持最近购买时间倒序 func buildPlayletBoughtData( ctx context.Context, rows []playletBoughtAggRow, playletMap map[int64]*playletmod.Playlet, progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress, themeNameMap map[int64]string, tagNameMap map[int64]string, ) []proto.PlayletBoughtData { result := make([]proto.PlayletBoughtData, 0, len(rows)) for _, row := range rows { item := playletMap[row.PlayletId] if item == nil { continue } if len(item.Description) > 0 { item.SeoInfo.Description = item.Description } progress := progressMap[item.Id] watchedCount := collectWatchedCount(progress, item.TotalEpisode) result = append(result, proto.PlayletBoughtData{ CommonData: proto.CommonData{ Id: item.Id, Status: int32(item.Status), IsFeatured: item.IsFeatured, IsRecommend: item.IsRecommend, IsNew: isOnlineThisWeek(item.OnlineAt), OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)), ShowOnBanner: item.ShowOnBanner, Banner: withImageDomain(ctx, item.Banner), OnlineAt: item.OnlineAt, MediaCreatedAt: item.MediaCreatedAt, MediaUpdatedAt: item.MediaUpdatedAt, ThemeIds: item.ThemeIds, ThemeNames: namesByIds(item.ThemeIds, themeNameMap), ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), CategoryIds: item.CategoryIds, Title: item.Title, PlayName: item.PlayName, Description: item.Description, Slug: item.Slug, Desc: item.Desc, Anchor: item.Anchor, Cover: withImageDomain(ctx, item.DisplayCover()), HorizontalCover: withImageDomain(ctx, item.DisplayHorizontalCover()), Preview: item.Preview, TagIds: item.TagIds, TagNames: namesByIds(item.TagIds, tagNameMap), TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), TotalEpisode: item.TotalEpisode, UpdateStatus: item.UpdateStatus, CountBrowse: item.CountBrowse, HeatValue: item.HeatValue, CountCollect: item.CountCollect, CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, SeoInfo: item.SeoInfo, }, BoughtChapterCount: row.BoughtChapterCount, CostCoins: row.CostCoins, BoughtAt: row.BoughtAt, LastChapterId: collectLastChapterId(progress), LastEpisode: collectLastEpisode(progress), WatchedCount: watchedCount, UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount), IsFinished: isPlayletFinished(item.TotalEpisode, watchedCount), }) } return result } // buildPlayletChapterCollectData 组装我的剧集收藏响应,保持playlet_chapter_collect的倒序分页顺序。 func buildPlayletChapterCollectData( ctx context.Context, collectList playletchaptercollectmod.PlayletChapterCollectList, chapterMap map[int64]*playletchaptermod.PlayletChapter, playletMap map[int64]*playletmod.Playlet, ) []proto.PlayletChapterCollectData { result := make([]proto.PlayletChapterCollectData, 0, len(collectList)) for _, collect := range collectList { if collect == nil { continue } chapter := chapterMap[collect.ChapterId] if chapter == nil { continue } playletId := collect.PlayletId if playletId <= 0 { playletId = chapter.PlayletId } playlet := playletMap[playletId] if playlet == nil { continue } result = append(result, proto.PlayletChapterCollectData{ PlayletId: playlet.Id, PlayletTitle: playlet.Title, PlayletCover: withImageDomain(ctx, playlet.DisplayCover()), TotalEpisode: playlet.TotalEpisode, UpdateStatus: playlet.UpdateStatus, ChapterId: chapter.Id, CurrentEpisode: chapter.CurrentEpisode, ChapterTitle: chapter.Title, VideoUrl: playletChapterPlayVideoUrl(chapter), MediaTime: chapter.MediaTime, PayType: int32(chapter.PayType), SingleCoins: chapter.SingleCoins, CountBrowse: chapter.CountBrowse, CountCollect: chapter.CountCollect, IsCollect: operateStatusYes, CollectedAt: collect.CreatedAt, }) } return result } // buildPlayletHistoryData 组装观看历史响应,保持playlet_watch_history的倒序分页顺序。 func buildPlayletHistoryData( ctx context.Context, historyList playletwatchhistorymod.PlayletWatchHistoryList, playletMap map[int64]*playletmod.Playlet, progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress, themeNameMap map[int64]string, tagNameMap map[int64]string, ) []proto.PlayletHistoryData { result := make([]proto.PlayletHistoryData, 0, len(historyList)) for _, history := range historyList { if history == nil { continue } item := playletMap[history.PlayletId] if item == nil { continue } progress := progressMap[item.Id] watchedCount := collectWatchedCount(progress, item.TotalEpisode) result = append(result, proto.PlayletHistoryData{ CommonData: proto.CommonData{ Id: item.Id, Status: int32(item.Status), IsFeatured: item.IsFeatured, IsRecommend: item.IsRecommend, IsNew: isOnlineThisWeek(item.OnlineAt), OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)), ShowOnBanner: item.ShowOnBanner, Banner: withImageDomain(ctx, item.Banner), OnlineAt: item.OnlineAt, MediaCreatedAt: item.MediaCreatedAt, MediaUpdatedAt: item.MediaUpdatedAt, ThemeIds: item.ThemeIds, ThemeNames: namesByIds(item.ThemeIds, themeNameMap), ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), CategoryIds: item.CategoryIds, Title: item.Title, PlayName: item.PlayName, Description: item.Description, Slug: item.Slug, Desc: item.Desc, Anchor: item.Anchor, Cover: withImageDomain(ctx, item.DisplayCover()), TagIds: item.TagIds, TagNames: namesByIds(item.TagIds, tagNameMap), TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), TotalEpisode: item.TotalEpisode, UpdateStatus: item.UpdateStatus, CountBrowse: item.CountBrowse, CountCollect: item.CountCollect, CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, SeoInfo: item.SeoInfo, }, LastChapterId: history.LastChapterId, LastEpisode: history.LastEpisode, WatchedCount: watchedCount, UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount), IsFinished: isPlayletFinished(item.TotalEpisode, watchedCount), HistoryAt: formatPlayletHistoryTime(history.UpdatedAt), }) } return result } func formatPlayletHistoryTime(t time.Time) string { if t.IsZero() { return "" } return t.In(utils.LocBJ).Format(pubdef.TimeISO) } // collectWatchedCount 读取已看集数,兼容仅保存watchedEpisodes但未写watchedCount的历史数据。 func collectWatchedCount(progress *playletwatchprogressmod.PlayletWatchProgress, totalEpisode int64) int64 { if progress == nil { return 0 } watchedCount := progress.WatchedCount if watchedCount <= 0 && len(progress.WatchedEpisodes) > 0 { watchedCount = int64(len(progress.WatchedEpisodes)) } if watchedCount < 0 { return 0 } if totalEpisode > 0 && watchedCount > totalEpisode { return totalEpisode } return watchedCount } // collectUnwatchedCount 计算未看集数,避免历史脏数据导致负数。 func collectUnwatchedCount(totalEpisode int64, watchedCount int64) int64 { if totalEpisode <= watchedCount { return 0 } return totalEpisode - watchedCount } // isPlayletFinished 判断短剧是否已看完。 func isPlayletFinished(totalEpisode int64, watchedCount int64) bool { return totalEpisode > 0 && watchedCount >= totalEpisode } // collectLastChapterId 获取最近观看剧集ID,无有效进度时返回0。 func collectLastChapterId(progress *playletwatchprogressmod.PlayletWatchProgress) int64 { if progress == nil || progress.LastChapterId < 0 { return 0 } return progress.LastChapterId } // collectLastEpisode 获取最近观看集数,无有效进度时返回0。 func collectLastEpisode(progress *playletwatchprogressmod.PlayletWatchProgress) int64 { if progress == nil || progress.LastEpisode < 0 { return 0 } return progress.LastEpisode } // findPlayletChapterUserOperateStatus 查询当前用户短剧剧集点赞和收藏状态 func findPlayletChapterUserOperateStatus(ctx context.Context, userId int64, chapterId int64) (int64, int64, *srvdef.SrvErr) { // 游客或无有效token时没有用户ID,直接返回未点赞、未收藏,保证剧集详情接口可匿名访问。 if userId <= 0 { return operateStatusNo, operateStatusNo, nil } likeList, err := playletchapterlikemod.FindManyPlayletChapterLike(ctx, bson.M{ playletchapterlikemod.FN.PlayletChapterLike.UserId: userId, playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId, }, options.Find(). SetLimit(1). SetProjection(bson.D{{Key: playletchapterlikemod.FN.PlayletChapterLike.Id, Value: 1}})) if err != nil { log.Error("查询短剧剧集用户点赞状态失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err)) return operateStatusNo, operateStatusNo, err } collectList, err := playletchaptercollectmod.FindManyPlayletChapterCollect(ctx, bson.M{ playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId, playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId, }, options.Find(). SetLimit(1). SetProjection(bson.D{{Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: 1}})) if err != nil { log.Error("查询短剧剧集用户收藏状态失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err)) return operateStatusNo, operateStatusNo, err } isLike := operateStatusByFlag(len(likeList) > 0) isCollect := operateStatusByFlag(len(collectList) > 0) return isLike, isCollect, nil } // incrementPlayletBrowse 原子增加短剧观看量和热度值 func incrementPlayletBrowse(ctx context.Context, id int64) *srvdef.SrvErr { // 不更新updatedAt,避免详情访问影响今日更新排序。 return playletmod.UpdateOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: id, }), bson.M{ "$inc": bson.M{ playletmod.FN.Playlet.CountBrowse: 1, playletmod.FN.Playlet.HeatValue: playletHeatClickInc, }, }) } func incrementPlayletHeatValue(ctx context.Context, id int64, delta int64) *srvdef.SrvErr { if id <= 0 || delta == 0 { return nil } return playletmod.UpdateOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: id, }, bson.M{ "$inc": bson.M{ playletmod.FN.Playlet.HeatValue: delta, }, }) } // checkPlayletForwardHeatLimit 校验同一用户或IP对同一短剧的转发加热频控 func checkPlayletForwardHeatLimit(userId int64, ip string, playletId int64) (bool, error) { operator := strings.TrimSpace(ip) if userId > 0 { operator = "user-" + strconv.FormatInt(userId, 10) } if operator == "" { operator = "unknown" } ok, err := redis.Setnx_NewOK( redisconst.AppPlayletForwardHeatKey(operator, playletId), 1, redisconst.AppPlayletForwardHeatExpire, ) if err != nil { return false, err } return !ok, nil } // findPlayletForwardHeatTarget 查询转发加热目标短剧,用于频控命中时返回当前热度 func findPlayletForwardHeatTarget(ctx context.Context, id int64) (*playletmod.Playlet, *srvdef.SrvErr) { playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: id, }), options.FindOne().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, })) if err != nil { log.Error("查询短剧转发热度目标失败", log.Any("短剧ID", id), log.E(err)) return nil, err } return playlet, nil } // incrementPlayletHeatValueAndReturn 原子增加短剧热度值并返回更新后的热度 func incrementPlayletHeatValueAndReturn(ctx context.Context, id int64, delta int64) (*playletmod.Playlet, *srvdef.SrvErr) { if id <= 0 || delta == 0 { return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") } return playletmod.FindOneAndUpdatePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: id, }), bson.M{ "$inc": bson.M{ playletmod.FN.Playlet.HeatValue: delta, }, }, options.FindOneAndUpdate().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, })) } // incrementPlayletChapterBrowse 原子增加短剧剧集观看量 func incrementPlayletChapterBrowse(ctx context.Context, id int64) *srvdef.SrvErr { // 只递增剧集countBrowse,不更新updatedAt,避免播放行为影响后台维护时间和排序逻辑。 return playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: id, }, bson.M{ "$inc": bson.M{ playletchaptermod.FN.PlayletChapter.CountBrowse: 1, }, }) } // findPlayletChapters 查询短剧全部章节,并批量填充当前用户播放权限。 // 原创短剧只返回已有水印地址的剧集:首次未处理好的不露出,换水印期间仍可播旧地址。 func findPlayletChapters(ctx context.Context, playletId int64, userId int64, isOriginal bool) ([]proto.PlayletChapterData, *srvdef.SrvErr) { filter := bson.M{ playletchaptermod.FN.PlayletChapter.PlayletId: playletId, } if isOriginal { filter[playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl] = bson.M{"$gt": ""} } // 详情页播放区需要展示完整选集列表,这里按剧集序号一次性查询全部章节,避免前端再发一次章节请求 list, err := playletchaptermod.FindManyPlayletChapter(ctx, filter, options.Find(). SetProjection(playletChapterInfoProjection). SetSort(bson.D{ {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, })) if err != nil { log.Error("查询短剧章节列表失败", log.Any("短剧ID", playletId), log.E(err)) return nil, err } access, err := buildPlayletChapterAccessContext(ctx, userId, playletId, list) if err != nil { return nil, err } result := make([]proto.PlayletChapterData, 0, len(list)) for _, chapter := range list { if chapter == nil { continue } result = append(result, playletChapterToData(chapter, access)) } return result, nil } // buildPlayletChapterAccessContext 批量查询详情章节列表所需的用户权限数据。 func buildPlayletChapterAccessContext(ctx context.Context, userId int64, playletId int64, chapters playletchaptermod.PlayletChapterList) (*playletChapterAccessContext, *srvdef.SrvErr) { access := &playletChapterAccessContext{ unlockedChapterIds: make(map[int64]bool), } if userId <= 0 { return access, nil } user, err := findPlayletChapterAccessUser(ctx, userId) if err != nil { return nil, err } access.balance = user.Recharge access.vipValid = isUserVipValid(user) coinChapterIds := collectCoinPayChapterIds(chapters) if len(coinChapterIds) == 0 { return access, nil } unlocks, err := playletchapterunlockmod.FindManyPlayletChapterUnlock(ctx, bson.M{ playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId, playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: playletId, playletchapterunlockmod.FN.PlayletChapterUnlock.ChapterId: bson.M{"$in": coinChapterIds}, }, options.Find().SetProjection(bson.D{ {Key: playletchapterunlockmod.FN.PlayletChapterUnlock.ChapterId, Value: 1}, })) if err != nil { return nil, err } for _, item := range unlocks { if item == nil || item.ChapterId <= 0 { continue } access.unlockedChapterIds[item.ChapterId] = true } return access, nil } // collectCoinPayChapterIds 收集金币付费剧集ID,减少解锁记录查询范围。 func collectCoinPayChapterIds(chapters playletchaptermod.PlayletChapterList) []int64 { ids := make([]int64, 0) for _, chapter := range chapters { if chapter == nil || chapter.Id <= 0 || chapter.PayType != playletchaptermod.PayTypeCoin { continue } ids = append(ids, chapter.Id) } return ids } // findGuessLikeCurrentPlaylet 查询猜你喜欢当前短剧 func findGuessLikeCurrentPlaylet(ctx context.Context, playletId int64) (*playletmod.Playlet, *srvdef.SrvErr) { item, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: playletId, }), options.FindOne().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, })) if err != nil { log.Error("查询猜你喜欢当前短剧失败", log.Any("短剧ID", playletId), log.E(err)) return nil, err } if item == nil { return nil, srvdef.New(srvdef.ErrNoData, "短剧不存在或已下架", "短剧不存在或已下架") } return item, nil } // playletGuessLikeFilter 构建猜你喜欢过滤条件 func playletGuessLikeFilter(item *playletmod.Playlet) bson.M { filter := homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.Id: bson.M{"$ne": item.Id}, }) if item != nil && len(item.ThemeIds) > 0 { filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": item.ThemeIds} } return filter } // playletGuessLikeSort 构建猜你喜欢排序 func playletGuessLikeSort() bson.D { return bson.D{ {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } // playletChapterToData 转换短剧章节响应数据 func playletChapterToData(item *playletchaptermod.PlayletChapter, access *playletChapterAccessContext) proto.PlayletChapterData { if item == nil { return proto.PlayletChapterData{} } videoURL := playletChapterPlayVideoUrl(item) data := proto.PlayletChapterData{ Id: item.Id, PlayletId: item.PlayletId, 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, PayType: int32(item.PayType), SingleCoins: item.SingleCoins, CostCoins: item.SingleCoins, } applyPlayletChapterDataWatchPermission(item, access, &data) if !data.CanWatch { videoURL = "" } data.VideoUrl = videoURL return data } // applyPlayletChapterDataWatchPermission 填充详情页章节列表的播放权限状态。 func applyPlayletChapterDataWatchPermission(item *playletchaptermod.PlayletChapter, access *playletChapterAccessContext, data *proto.PlayletChapterData) { if item == nil || data == nil { return } if access != nil { data.Balance = access.balance data.VipValid = access.vipValid } switch item.PayType { case playletchaptermod.PayTypeFree: data.CanWatch = true case playletchaptermod.PayTypeCoin: unlocked := access != nil && access.unlockedChapterIds[item.Id] data.IsUnlocked = unlocked data.CanWatch = unlocked data.NeedUnlock = !unlocked case playletchaptermod.PayTypeVip: vipValid := access != nil && access.vipValid data.CanWatch = vipValid data.NeedVip = !vipValid default: data.NeedUnlock = true } } // playletChapterToInfoResp 转换短剧剧集详情响应数据 func playletChapterToInfoResp(item *playletchaptermod.PlayletChapter) *proto.PlayletChapterInfoResp { // 剧集详情只返回播放页需要的当前剧集字段;播放地址优先水印地址,为空回退原片。 if item == nil { return &proto.PlayletChapterInfoResp{} } return &proto.PlayletChapterInfoResp{ Id: item.Id, PlayletId: item.PlayletId, CurrentEpisode: item.CurrentEpisode, Title: item.Title, Md5: item.Md5, Hash: item.Hash, Height: item.Height, Width: item.Width, Ratio: item.Ratio, VideoUrl: playletChapterPlayVideoUrl(item), MediaSize: item.MediaSize, MediaTime: item.MediaTime, CountComment: item.CountComment, CountBrowse: item.CountBrowse, CountCollect: item.CountCollect, CountLike: item.CountLike, IsLike: operateStatusNo, IsCollect: operateStatusNo, PayType: int32(item.PayType), SingleCoins: item.SingleCoins, } } // ensureOriginalChapterWatermarkReady 原创剧集必须已有水印地址才允许播放/解锁。 func ensureOriginalChapterWatermarkReady(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) *srvdef.SrvErr { if playlet == nil || !playlet.IsOriginal || chapter == nil { return nil } if strings.TrimSpace(chapter.WatermarkVideoUrl) != "" { return nil } return srvdef.New(srvdef.ErrNoData, "剧集处理中,请稍后观看", "剧集处理中,请稍后观看") } // playletChapterPlayVideoUrl 优先返回水印地址,为空时回退原片地址。 func playletChapterPlayVideoUrl(item *playletchaptermod.PlayletChapter) string { if item == nil { return "" } if u := strings.TrimSpace(item.WatermarkVideoUrl); u != "" { return u } return item.VideoUrl } // buildPlayletDetailSeoInfo 组装短剧详情页SEO信息 func buildPlayletDetailSeoInfo(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string) *proto.SeoInfo { seo := configser.GetSiteSeoInfo() if item == nil { return &seo } // 优先使用后台配置的 SeoInfo,缺失字段再回退剧名/简介自动生成。 if title := strings.TrimSpace(item.SeoInfo.Title); title != "" { seo.Title = title } else if title := strings.TrimSpace(item.Title); title != "" { seo.Title = title } if desc := strings.TrimSpace(item.Description); desc != "" { seo.Description = desc } else if desc := strings.TrimSpace(item.SeoInfo.Description); desc != "" { seo.Description = desc } else if desc := truncatePlayletSEODescription(strings.TrimSpace(item.Desc), 120); desc != "" { seo.Description = desc } if len(item.SeoInfo.Keyworld) > 0 { seo.Keywords = append([]string{}, item.SeoInfo.Keyworld...) } else { seo.Keywords = buildPlayletSEOKeywords(item, themeNameMap, tagNameMap, seo.Keywords) } seo.CanonicalURL = buildPlayletDetailCanonicalURL(seo.CanonicalURL, item.Slug, item.Id) return &seo } // buildPlayletChapterDetailSeoInfo 组装短剧剧集详情页SEO信息 func buildPlayletChapterDetailSeoInfo(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) *proto.SeoInfo { seo := configser.GetSiteSeoInfo() if playlet == nil && chapter == nil { return &seo } // 优先使用短剧后台配置的 SeoInfo,再回退到剧名/简介自动生成。 if playlet != nil { if title := strings.TrimSpace(playlet.SeoInfo.Title); title != "" { seo.Title = title } if desc := strings.TrimSpace(playlet.Description); desc != "" { seo.Description = desc } else if desc := strings.TrimSpace(playlet.SeoInfo.Description); desc != "" { seo.Description = desc } if len(playlet.SeoInfo.Keyworld) > 0 { seo.Keywords = append([]string{}, playlet.SeoInfo.Keyworld...) } } if strings.TrimSpace(seo.Title) == "" { if title := buildPlayletChapterSEOTitle(playlet, chapter); title != "" { seo.Title = title } } else if chapter != nil { // 已有短剧 SEO 标题时,补充剧集名便于播放页区分。 if episodeTitle := strings.TrimSpace(chapter.Title); episodeTitle != "" { seo.Title = seo.Title + " " + episodeTitle } else if chapter.CurrentEpisode > 0 { seo.Title = seo.Title + " 第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集" } } if strings.TrimSpace(seo.Description) == "" && playlet != nil { if desc := truncatePlayletSEODescription(strings.TrimSpace(playlet.Desc), 120); desc != "" { seo.Description = desc } } if len(seo.Keywords) == 0 { if playlet != nil { seo.Keywords = buildPlayletSEOKeywords(playlet, nil, nil, seo.Keywords) } else { seo.Keywords = buildPlayletChapterSEOKeywords(chapter, seo.Keywords) } } if chapter != nil { slug := "" if playlet != nil { slug = playlet.Slug } seo.CanonicalURL = buildPlayletChapterDetailCanonicalURL(seo.CanonicalURL, slug, chapter.PlayletId, chapter.Id) } return &seo } // buildPlayletListPageSeoInfo 组装短剧总列表页 SEO(无题材/分类筛选)。 func buildPlayletListPageSeoInfo() *proto.SeoInfo { return buildPageSeoInfoFromValue(pubdef.SeoValue{}, "", "", "/list") } // buildPlayletThemePageSeoInfo 组装题材短剧列表页SEO信息(读题材缓存,避免每次查库) func buildPlayletThemePageSeoInfo(themeId int64) *proto.SeoInfo { if themeId <= 0 { return buildPlayletListPageSeoInfo() } theme := playletcache.GetEnabledTheme(themeId) if theme == nil { return buildPlayletListPageSeoInfo() } return buildPageSeoInfoFromValue(theme.SeoInfo, theme.Name, "", buildPlayletThemeListCanonicalURL(theme.Slug, theme.Id)) } // buildPlayletTagPageSeoInfo 组装标签短剧列表页SEO信息(读标签缓存,避免每次查库) func buildPlayletTagPageSeoInfo(tagId int64) *proto.SeoInfo { if tagId <= 0 { return buildPlayletListPageSeoInfo() } tag := playletcache.GetEnabledTag(tagId) if tag == nil { return buildPageSeoInfoFromValue(pubdef.SeoValue{}, "短剧标签", "", buildPlayletTagListCanonicalURL("", tagId)) } return buildPageSeoInfoFromValue(tag.SeoInfo, tag.Name, "", buildPlayletTagListCanonicalURL(tag.Slug, tag.Id)) } // buildPageSeoInfoFromValue 将后台 SeoValue 转为页面 SeoInfo,缺失字段回退站点默认。 func buildPageSeoInfoFromValue(custom pubdef.SeoValue, fallbackTitle, fallbackDesc, canonicalURL string) *proto.SeoInfo { seo := configser.GetSiteSeoInfo() if title := strings.TrimSpace(custom.Title); title != "" { seo.Title = title } else if title := strings.TrimSpace(fallbackTitle); title != "" { seo.Title = title } if desc := strings.TrimSpace(custom.Description); desc != "" { seo.Description = desc } else if desc := strings.TrimSpace(fallbackDesc); desc != "" { seo.Description = desc } if len(custom.Keyworld) > 0 { seo.Keywords = append([]string{}, custom.Keyworld...) } if canonicalURL != "" { seo.CanonicalURL = buildPlayletCanonicalURL(seo.CanonicalURL, canonicalURL) } return &seo } // buildPlayletThemeListCanonicalURL 专题详情页相对路径(对齐 jha-1045-hgdjnet-web /topic-detail/:slug) func buildPlayletThemeListCanonicalURL(slug string, themeId int64) string { return h5path.TopicDetail(slug, themeId) } // buildPlayletTagListCanonicalURL 标签列表页相对路径(对齐 jha-1045-hgdjnet-web /tags/:slug) func buildPlayletTagListCanonicalURL(slug string, tagId int64) string { return h5path.Tag(slug, tagId) } func playletSiteEntityPath(prefix, slug string, id int64) string { return h5path.Entity(prefix, slug, id) } // buildPlayletChapterSEOTitle 生成剧集详情页标题,优先展示“短剧名 + 剧集名”。 func buildPlayletChapterSEOTitle(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) string { playletTitle := "" if playlet != nil { playletTitle = strings.TrimSpace(playlet.Title) } chapterTitle := "" if chapter != nil { chapterTitle = strings.TrimSpace(chapter.Title) if chapterTitle == "" && chapter.CurrentEpisode > 0 { chapterTitle = "第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集" } } if playletTitle == "" { return chapterTitle } if chapterTitle == "" { return playletTitle } return playletTitle + " " + chapterTitle } // buildPlayletSEOKeywords 合并短剧标题、题材、标签和全站关键词,并去重。 func buildPlayletSEOKeywords(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string, siteKeywords []string) []string { capacity := len(siteKeywords) + 1 if item != nil { capacity += len(item.ThemeIds) + len(item.TagIds) } keywords := make([]string, 0, capacity) seen := make(map[string]struct{}, cap(keywords)) add := func(value string) { value = strings.TrimSpace(value) if value == "" { return } if _, ok := seen[value]; ok { return } seen[value] = struct{}{} keywords = append(keywords, value) } if item != nil { add(item.Title) for _, name := range namesByIds(item.ThemeIds, themeNameMap) { add(name) } for _, name := range namesByIds(item.TagIds, tagNameMap) { add(name) } } for _, keyword := range siteKeywords { add(keyword) } return keywords } // buildPlayletChapterSEOKeywords 合并剧集标题和全站关键词,并去重。 func buildPlayletChapterSEOKeywords(chapter *playletchaptermod.PlayletChapter, siteKeywords []string) []string { keywords := make([]string, 0, len(siteKeywords)+1) seen := make(map[string]struct{}, cap(keywords)) add := func(value string) { value = strings.TrimSpace(value) if value == "" { return } if _, ok := seen[value]; ok { return } seen[value] = struct{}{} keywords = append(keywords, value) } if chapter != nil { add(chapter.Title) } for _, keyword := range siteKeywords { add(keyword) } return keywords } // truncatePlayletSEODescription 限制meta description长度,避免详情长文本直接写进SEO描述。 func truncatePlayletSEODescription(desc string, maxRunes int) string { if maxRunes <= 0 || desc == "" { return "" } runes := []rune(desc) if len(runes) <= maxRunes { return desc } return string(runes[:maxRunes]) } // buildPlayletDetailCanonicalURL 拼接短剧详情页标准URL。 func buildPlayletDetailCanonicalURL(baseURL, slug string, id int64) string { return buildPlayletCanonicalURL(baseURL, playletSiteEntityPath(playletH5DetailPath, slug, id)) } // buildPlayletChapterDetailCanonicalURL 拼接短剧剧集详情页标准URL。 func buildPlayletChapterDetailCanonicalURL(baseURL, slug string, playletId int64, chapterId int64) string { pagePath := h5path.PlayletChapter(slug, playletId, chapterId) if pagePath == "" { pagePath = h5path.Playlet(slug, playletId) } return buildPlayletCanonicalURL(baseURL, pagePath) } // buildPlayletCanonicalURL 使用全站Web地址拼接页面路径;未配置域名时返回相对路径。 func buildPlayletCanonicalURL(baseURL string, pagePath string) string { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") if baseURL == "" { return pagePath } return baseURL + pagePath } // emptyPlayletSearchResp 构建短剧ES空分页响应 func emptyPlayletSearchResp(ctx context.Context, keyword string, pageNum int64, pageSize int64) *proto.PlayletSearchResp { // ES索引未创建或暂无数据时返回空列表,保持接口响应结构稳定。 return &proto.PlayletSearchResp{ TotalCount: 0, PageSize: int(pageSize), PageNum: int(pageNum), Keyword: keyword, List: make([]proto.CommonData, 0), EveryoneWatching: guessLikeCommonData(ctx), } } // buildHomePlayletResponse 查询并组装app首页短剧数据 func buildHomePlayletResponse(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) { // 定义返回 resp := &proto.HomePlayletResponse{ HomeBannerData: make([]*proto.CommonData, 0), HomeConfigData: make([]*proto.HomeConfigData, 0), Announcements: make([]*proto.AnnouncementItem, 0), } // Home首页数据响应 bannerList, err := findHomeBanners(ctx) if err != nil { log.Error("查询首页banner数据失败", log.E(err)) return nil, err } configList, err := findHomeConfigs(ctx) if err != nil { log.Error("查询首页配置数据失败", log.E(err)) return nil, err } configPlayletMap, configVideoMap, configThemeMap, configPlaylets, err := findHomeConfigPlaylets(ctx, configList) if err != nil { log.Error("查询首页配置短剧数据失败", log.E(err)) return nil, err } allPlaylets := mergeHomePlayletLists(bannerList, configPlaylets) themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets) if err != nil { log.Error("查询首页配置题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets) if err != nil { log.Error("查询首页配置标签名称失败", log.E(err)) return nil, err } resp.HomeBannerData = playletsToHomeCommonData(ctx, bannerList, themeNameMap, tagNameMap) resp.HomeConfigData = homeConfigsToHomeData(ctx, configList, configPlayletMap, configVideoMap, configThemeMap, themeNameMap, tagNameMap) seo := configser.GetSiteSeoInfo() resp.SeoInfo = &seo return resp, nil } func attachHomeAnnouncements(resp *proto.HomePlayletResponse) { if resp == nil { return } resp.Announcements = configser.GetAnnouncements() } func attachHomeAdvitise(resp *proto.HomePlayletResponse) { if resp == nil { return } resp.Advitise = configser.GetAdvitise() } // fillHomeConfigPlayletDetail 分页填充首页配置短剧数据 func fillHomeConfigPlayletDetail(ctx context.Context, resp *proto.HomeConfigDetailListResp, sortType int32, req *proto.HomeConfigDetailListReq, pageNum, pageSize int64) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) { if sortType == homeconfigmod.SortTypeRecommend { return fillHomeConfigRecommendPlayletDetail(ctx, resp, req, pageNum, pageSize) } filter, sort := homeConfigPlayletQuery(sortType, homeConfigCategoryID(sortType, resp.HomeConfigId)) if err := applyHomeConfigPlayletDetailFilter(ctx, filter, req); err != nil { return nil, err } totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp.TotalCount = totalCount if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } categoryId := homeConfigCategoryID(sortType, resp.HomeConfigId) list, err := findHomePlayletsByHomeConfigSort(ctx, filter, sortType, categoryId, sort, skip, pageSize) if err != nil { return nil, err } return fillHomeConfigPlayletDetailList(ctx, resp, list) } func fillHomeConfigRecommendPlayletDetail(ctx context.Context, resp *proto.HomeConfigDetailListResp, req *proto.HomeConfigDetailListReq, pageNum, pageSize int64) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) { baseFilter := homeUpPlayletFilter(nil) if err := applyHomeConfigPlayletDetailFilter(ctx, baseFilter, req); err != nil { return nil, err } totalCount, err := playletmod.CountPlaylet(ctx, baseFilter) if err != nil { return nil, err } resp.TotalCount = totalCount if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } recommendFilter, recommendSort := homeConfigPlayletQuery(homeconfigmod.SortTypeRecommend, 0) if err := applyHomeConfigPlayletDetailFilter(ctx, recommendFilter, req); err != nil { return nil, err } recommendTotal, err := playletmod.CountPlaylet(ctx, recommendFilter) if err != nil { return nil, err } list := make(playletmod.PlayletList, 0, pageSize) if skip < recommendTotal { recommendLimit := pageSize if left := recommendTotal - skip; left < recommendLimit { recommendLimit = left } recommendList, err := findHomePlaylets(ctx, recommendFilter, recommendSort, recommendLimit, options.Find().SetSkip(skip)) if err != nil { return nil, err } list = append(list, recommendList...) } remain := pageSize - int64(len(list)) if remain > 0 { fallbackFilter, fallbackSort := homeConfigPlayletQuery(homeconfigmod.SortTypeLatest, 0) if err := applyHomeConfigPlayletDetailFilter(ctx, fallbackFilter, req); err != nil { return nil, err } fallbackFilter[playletmod.FN.Playlet.IsRecommend] = bson.M{"$ne": true} fallbackSkip := int64(0) if skip > recommendTotal { fallbackSkip = skip - recommendTotal } fallbackList, err := findHomePlaylets(ctx, fallbackFilter, fallbackSort, remain, options.Find().SetSkip(fallbackSkip)) if err != nil { return nil, err } list = append(list, fallbackList...) } return fillHomeConfigPlayletDetailList(ctx, resp, list) } func fillHomeConfigPlayletDetailList(ctx context.Context, resp *proto.HomeConfigDetailListResp, list playletmod.PlayletList) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) { themeNameMap, err := buildHomeThemeNameMap(ctx, list) if err != nil { return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, list) if err != nil { return nil, err } resp.List = playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap) return resp, nil } // applyHomeConfigPlayletDetailFilter 追加首页配置详情短剧筛选条件 func applyHomeConfigPlayletDetailFilter(ctx context.Context, filter bson.M, req *proto.HomeConfigDetailListReq) *srvdef.SrvErr { if req == nil { return nil } if req.OperateType > 0 { operateType := playletmod.OperateType(req.OperateType) if !playletmod.IsValidOperateType(operateType) { return srvdef.New(srvdef.ErrParamError, "运营类型无效", "") } for key, value := range operateTypeFilter(operateType) { filter[key] = value } } themeID, err := resolveOptionalThemeID(ctx, req.ThemeId, req.ThemeSlug) if err != nil { return err } if themeID > 0 { filter[playletmod.FN.Playlet.ThemeIds] = themeID } return applyHomeConfigTimeRangeFilter(filter, playletmod.FN.Playlet.OnlineAt, req.TimeRange) } // fillHomeConfigVideoDetail 分页填充首页配置视频数据 func fillHomeConfigVideoDetail(ctx context.Context, resp *proto.HomeConfigDetailListResp, sortType int32, req *proto.HomeConfigDetailListReq, pageNum, pageSize int64) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) { filter := homeConfigVideoFilter() applyHomeConfigVideoCategoryFilter(filter, sortType, resp.HomeConfigId) if err := applyHomeConfigVideoDetailFilter(filter, req); err != nil { return nil, err } totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter) if err != nil { return nil, err } resp.TotalCount = totalCount if totalCount == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= totalCount { return resp, nil } list, err := findHomeConfigVideosPage(ctx, filter, skip, pageSize) if err != nil { return nil, err } resp.VideoList = videosToHomeConfigData(ctx, list) return resp, nil } // applyHomeConfigVideoDetailFilter 追加首页配置详情视频筛选条件 func applyHomeConfigVideoDetailFilter(filter bson.M, req *proto.HomeConfigDetailListReq) *srvdef.SrvErr { if req == nil { return nil } mediaType, err := homeConfigDetailVideoMediaType(req.MediaType) if err != nil { return err } if req.MediaType > 0 { filter[videoinformationmod.FN.VideoInformation.MediaType] = mediaType } if req.TopicId > 0 { filter[videoinformationmod.FN.VideoInformation.TopicIds] = req.TopicId } return applyHomeConfigTimeRangeFilter(filter, videoinformationmod.FN.VideoInformation.AddedTime, req.TimeRange) } // fillHomeConfigThemeDetail 分页填充首页配置题材数据 func fillHomeConfigThemeDetail(ctx context.Context, resp *proto.HomeConfigDetailListResp, sortType int32, pageNum, pageSize int64) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) { skip := (pageNum - 1) * pageSize groups, totalCount, playlets, err := findHomeConfigThemePlayletsPage(ctx, sortType, skip, pageSize) if err != nil { return nil, err } resp.TotalCount = totalCount if len(groups) == 0 { return resp, nil } themeNameMap, err := buildHomeThemeNameMap(ctx, playlets) if err != nil { return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, playlets) if err != nil { return nil, err } resp.ThemeList = homeConfigThemesToHomeData(ctx, groups, themeNameMap, tagNameMap) return resp, nil } func guessLikeCommonData(ctx context.Context) []*proto.CommonData { list := playletcache.ListGuessLike() themeNameMap, _ := buildHomeThemeNameMap(ctx, list) tagNameMap, _ := buildHomeTagNameMap(ctx, list) return playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap) } // findHomeBanners 查询展示在首页 banner 的上架短剧 func findHomeBanners(ctx context.Context) (playletmod.PlayletList, *srvdef.SrvErr) { return findHomePlaylets(ctx, homeUpPlayletFilter(bson.M{ playletmod.FN.Playlet.ShowOnBanner: true, }), bson.D{ {Key: playletmod.FN.Playlet.TopRank, Value: -1}, {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, }, homeBannerLimit) } // findHomeConfigs 查询启用的首页配置 func findHomeConfigs(ctx context.Context) (homeconfigmod.HomeConfigList, *srvdef.SrvErr) { return homeconfigmod.FindManyHomeConfig(ctx, bson.M{ homeconfigmod.FN.HomeConfig.Status: true, }, options.Find(). SetProjection(homeConfigAppProjection). SetSort(bson.D{ {Key: homeconfigmod.FN.HomeConfig.Rank, Value: -1}, {Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: -1}, {Key: homeconfigmod.FN.HomeConfig.Id, Value: -1}, })) } // findEnabledHomeConfig 按 slug 或 ID 查询启用的首页配置,slug 优先。 func findEnabledHomeConfig(ctx context.Context, id int64, slug string) (*homeconfigmod.HomeConfig, *srvdef.SrvErr) { slug = strings.TrimSpace(slug) if slug != "" && !utils.IsSlugAllDigits(slug) { return findEnabledHomeConfigByFilter(ctx, bson.M{ homeconfigmod.FN.HomeConfig.Slug: slug, homeconfigmod.FN.HomeConfig.Status: true, }) } if id <= 0 && utils.IsSlugAllDigits(slug) { if n, err := strconv.ParseInt(slug, 10, 64); err == nil { id = n } } if id <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "首页配置无效", "") } return findEnabledHomeConfigByFilter(ctx, bson.M{ homeconfigmod.FN.HomeConfig.Id: id, homeconfigmod.FN.HomeConfig.Status: true, }) } func findEnabledHomeConfigByFilter(ctx context.Context, filter bson.M) (*homeconfigmod.HomeConfig, *srvdef.SrvErr) { list, err := homeconfigmod.FindManyHomeConfig(ctx, filter, options.Find(). SetProjection(homeConfigAppProjection). SetLimit(1)) if err != nil { return nil, err } if len(list) == 0 || list[0] == nil { return nil, srvdef.New(srvdef.ErrNoData, "", "") } return list[0], nil } // findHomeConfigPlaylets 按首页配置查询短剧集合 func findHomeConfigPlaylets(ctx context.Context, configs homeconfigmod.HomeConfigList) (map[int64]playletmod.PlayletList, map[int64]videoinformationmod.VideoInformationList, map[int64][]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) { configPlayletMap := make(map[int64]playletmod.PlayletList, len(configs)) configVideoMap := make(map[int64]videoinformationmod.VideoInformationList, len(configs)) configThemeMap := make(map[int64][]homeConfigThemePlaylets, len(configs)) allPlaylets := make(playletmod.PlayletList, 0) for _, config := range configs { if config == nil { continue } switch config.Type { case homeconfigmod.TypePlaylet: list, err := findHomeConfigPlayletList(ctx, config.SortType, homeConfigCategoryID(config.SortType, config.Id)) if err != nil { return nil, nil, nil, nil, err } configPlayletMap[config.Id] = list allPlaylets = append(allPlaylets, list...) case homeconfigmod.TypeVideo: list, err := findHomeConfigVideos(ctx, config.SortType, config.Id) if err != nil { return nil, nil, nil, nil, err } configVideoMap[config.Id] = list case homeconfigmod.TypeTheme: groups, list, err := findHomeConfigThemePlaylets(ctx, config.SortType) if err != nil { return nil, nil, nil, nil, err } configThemeMap[config.Id] = groups allPlaylets = append(allPlaylets, list...) } } return configPlayletMap, configVideoMap, configThemeMap, allPlaylets, nil } func findHomeConfigPlayletList(ctx context.Context, sortType int32, categoryId int64) (playletmod.PlayletList, *srvdef.SrvErr) { filter, sort := homeConfigPlayletQuery(sortType, categoryId) list, err := findHomePlayletsByHomeConfigSort(ctx, filter, sortType, categoryId, sort, 0, homeConfigPlayletLimit) if err != nil { return nil, err } if sortType != homeconfigmod.SortTypeRecommend || int64(len(list)) >= homeConfigPlayletLimit { return list, nil } filter, sort = homeConfigPlayletQuery(homeconfigmod.SortTypeLatest, 0) if len(list) > 0 { filter[playletmod.FN.Playlet.Id] = bson.M{"$nin": playletIdsFromList(list)} } moreList, err := findHomePlaylets(ctx, filter, sort, homeConfigPlayletLimit-int64(len(list))) if err != nil { return nil, err } return append(list, moreList...), nil } func findHomePlayletsByHomeConfigSort(ctx context.Context, filter bson.M, sortType int32, categoryId int64, sort bson.D, skip, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) { if sortType == homeconfigmod.SortTypeCategory && categoryId > 0 { return playletmod.FindManyPlayletByCategoryRank(ctx, filter, categoryId, skip, limit, playletmod.CategoryRankSort(), playletHomeProjection) } return findHomePlaylets(ctx, filter, sort, limit, options.Find().SetSkip(skip)) } func playletIdsFromList(list playletmod.PlayletList) []int64 { ids := make([]int64, 0, len(list)) for _, item := range list { if item == nil || item.Id <= 0 { continue } ids = append(ids, item.Id) } return ids } func findHomeConfigVideos(ctx context.Context, sortType int32, configId int64) (videoinformationmod.VideoInformationList, *srvdef.SrvErr) { filter := homeConfigVideoFilter() applyHomeConfigVideoCategoryFilter(filter, sortType, configId) return findHomeConfigVideosPage(ctx, filter, 0, homeConfigVideoLimit) } // findHomeConfigVideosPage 分页查询首页配置视频 func findHomeConfigVideosPage(ctx context.Context, filter bson.M, skip, limit int64) (videoinformationmod.VideoInformationList, *srvdef.SrvErr) { if filter == nil { filter = homeConfigVideoFilter() } return videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find(). SetProjection(homeConfigVideoProjection). SetSort(bson.D{ {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}, {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, }). SetSkip(skip). SetLimit(limit)) } // homeConfigVideoFilter 构建首页配置视频查询条件 func homeConfigVideoFilter() bson.M { return bson.M{ videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, } } func applyHomeConfigVideoCategoryFilter(filter bson.M, sortType int32, configId int64) { applyVideoCategoryIdsFilter(filter, homeConfigCategoryID(sortType, configId)) } func applyVideoCategoryIdsFilter(filter bson.M, categoryId int64) { if filter == nil || categoryId <= 0 { return } filter[videoinformationmod.FN.VideoInformation.CategoryIds] = bson.M{"$in": []int64{categoryId}} } // resolveOptionalThemeID 解析可选题材ID func resolveOptionalThemeID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) { if strings.TrimSpace(slug) == "" && id <= 0 { return 0, nil } return resolveThemeID(ctx, id, slug) } // homeConfigDetailVideoMediaType 转换首页配置详情视频类型 func homeConfigDetailVideoMediaType(mediaType int64) (videoinformationmod.MediaType, *srvdef.SrvErr) { switch mediaType { case 0: return 0, nil case 1: return videoinformationmod.MediaType_Collection, nil case 2: return videoinformationmod.MediaType_VideoV2, nil default: return 0, srvdef.New(srvdef.ErrParamError, "类型无效", "") } } // applyHomeConfigTimeRangeFilter 追加首页配置详情时间范围 func applyHomeConfigTimeRangeFilter(filter bson.M, field string, timeRange int64) *srvdef.SrvErr { switch timeRange { case 0: return nil case 1: filter[field] = bson.M{"$gte": weekStartTime(time.Now())} return nil case 2: filter[field] = bson.M{"$gte": monthStartTime(time.Now())} return nil default: return srvdef.New(srvdef.ErrParamError, "时间范围无效", "") } } // weekStartTime 返回本周开始时间 func weekStartTime(now time.Time) time.Time { weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 } return time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location()) } // monthStartTime 返回本月开始时间 func monthStartTime(now time.Time) time.Time { return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) } func findHomeConfigThemePlaylets(ctx context.Context, sortType int32) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) { themes := playletcache.ListEnabledThemesByPlayletCount(0) result := make([]homeConfigThemePlaylets, 0, homeConfigThemeLimit) allPlaylets := make(playletmod.PlayletList, 0) for _, theme := range themes { if theme == nil { continue } filter, sort := homeConfigThemePlayletQuery(sortType, theme.Id) list, err := findHomePlaylets(ctx, filter, sort, homeConfigPlayletLimit) if err != nil { return nil, nil, err } if len(list) == 0 { continue } result = append(result, homeConfigThemePlaylets{ Theme: theme, Playlets: list, }) allPlaylets = append(allPlaylets, list...) if len(result) >= homeConfigThemeLimit { break } } return result, allPlaylets, nil } // findHomeConfigThemePlayletsPage 分页查询有短剧数据的首页配置题材 func findHomeConfigThemePlayletsPage(ctx context.Context, sortType int32, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) { themes := playletcache.ListEnabledThemesByPlayletCount(0) result := make([]homeConfigThemePlaylets, 0) allPlaylets := make(playletmod.PlayletList, 0) totalCount := int64(0) for _, theme := range themes { if theme == nil { continue } filter, sort := homeConfigThemePlayletQuery(sortType, theme.Id) count, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, 0, nil, err } if count == 0 { continue } if totalCount >= skip && int64(len(result)) < limit { list, err := findHomePlaylets(ctx, filter, sort, homeConfigPlayletLimit) if err != nil { return nil, 0, nil, err } result = append(result, homeConfigThemePlaylets{ Theme: theme, Playlets: list, }) allPlaylets = append(allPlaylets, list...) } totalCount++ } return result, totalCount, allPlaylets, nil } // findPlayletTopicPage 分页查询有短剧数据的专题剧单 func findPlayletTopicPage(ctx context.Context, sortType int64, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) { if sortType == topicListSortHot { return findPlayletTopicPageByHot(ctx, skip, limit) } return findPlayletTopicPageByLatest(ctx, skip, limit) } // fillPlayletTopicDetailMore 填充专题详情更多专题 func fillPlayletTopicDetailMore(ctx context.Context, resp *proto.PlayletTopicDetailResp, currentThemeID int64, seedPlaylets playletmod.PlayletList) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) { if resp == nil { return resp, nil } groups, morePlaylets, err := findPlayletTopicMoreGroups(ctx, currentThemeID, topicDetailMoreLimit) if err != nil { return nil, err } allPlaylets := mergeHomePlayletLists(seedPlaylets, morePlaylets) themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets) if err != nil { log.Error("查询专题详情更多专题题材名称失败", log.E(err)) return nil, err } tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets) if err != nil { log.Error("查询专题详情更多专题标签名称失败", log.E(err)) return nil, err } resp.MoreTopics = playletTopicsToPageData(ctx, groups, themeNameMap, tagNameMap) return resp, nil } // findPlayletTopicMoreGroups 查询更多专题 func findPlayletTopicMoreGroups(ctx context.Context, currentThemeID int64, limit int64) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) { themes := playletcache.ListEnabledThemesByPlayletCount(0) selected := make([]*playletcache.ThemeItem, 0, limit) for _, theme := range themes { if theme == nil || theme.Id == currentThemeID { continue } selected = append(selected, theme) if int64(len(selected)) >= limit { break } } groups, _, playlets, err := buildPlayletTopicGroups(ctx, selected, int64(len(selected)), topicListSortHot) if err != nil { return nil, nil, err } return groups, playlets, nil } // findPlayletTopicPageByHot 按绑定短剧数量分页查询专题 func findPlayletTopicPageByHot(ctx context.Context, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) { themes := playletcache.ListEnabledThemesByPlayletCount(0) totalCount := int64(len(themes)) if skip >= totalCount { return make([]homeConfigThemePlaylets, 0), totalCount, make(playletmod.PlayletList, 0), nil } end := skip + limit if end > totalCount { end = totalCount } return buildPlayletTopicGroups(ctx, themes[skip:end], totalCount, topicListSortHot) } // findPlayletTopicPageByLatest 按更新时间分页查询专题 func findPlayletTopicPageByLatest(ctx context.Context, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *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}, }). SetSort(bson.D{ {Key: playletthememod.FN.PlayletTheme.UpdatedAt, Value: -1}, {Key: playletthememod.FN.PlayletTheme.Rank, Value: -1}, {Key: playletthememod.FN.PlayletTheme.Id, Value: -1}, })) if err != nil { return nil, 0, nil, err } themes := make([]*playletcache.ThemeItem, 0, len(list)) for _, item := range list { if item == nil { continue } theme := playletcache.GetEnabledTheme(item.Id) if theme == nil || theme.PlayletCount <= 0 { continue } themes = append(themes, theme) } totalCount := int64(len(themes)) if skip >= totalCount { return make([]homeConfigThemePlaylets, 0), totalCount, make(playletmod.PlayletList, 0), nil } end := skip + limit if end > totalCount { end = totalCount } return buildPlayletTopicGroups(ctx, themes[skip:end], totalCount, topicListSortLatest) } // buildPlayletTopicGroups 查询专题卡片需要的短剧数据 func buildPlayletTopicGroups(ctx context.Context, themes []*playletcache.ThemeItem, totalCount int64, sortType int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) { result := make([]homeConfigThemePlaylets, 0, len(themes)) allPlaylets := make(playletmod.PlayletList, 0) for _, theme := range themes { if theme == nil { continue } filter, sort := playletTopicPagePlayletQuery(sortType, theme.Id) list, err := findHomePlaylets(ctx, filter, sort, topicListPlayletLimit) if err != nil { return nil, 0, nil, err } if len(list) == 0 { continue } result = append(result, homeConfigThemePlaylets{ Theme: theme, Playlets: list, }) allPlaylets = append(allPlaylets, list...) } return result, totalCount, allPlaylets, nil } // playletTopicPagePlayletQuery 构建专题卡片短剧查询条件 func playletTopicPagePlayletQuery(sortType int64, themeID int64) (bson.M, bson.D) { filter := homeUpPlayletFilter(nil) filter[playletmod.FN.Playlet.ThemeIds] = themeID if sortType == topicListSortHot { return filter, bson.D{ {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } return filter, bson.D{ {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } // homeConfigThemePlayletQuery 构建题材下短剧查询条件 func homeConfigThemePlayletQuery(sortType int32, themeID int64) (bson.M, bson.D) { filter, sort := homeConfigPlayletQuery(sortType, 0) filter[playletmod.FN.Playlet.ThemeIds] = themeID return filter, sort } func homeConfigCategoryID(sortType int32, configId int64) int64 { if sortType == homeconfigmod.SortTypeCategory && configId > 0 { return configId } return 0 } func homeConfigPlayletQuery(sortType int32, categoryId int64) (bson.M, bson.D) { filter := homeUpPlayletFilter(nil) switch sortType { case homeconfigmod.SortTypeRecommend: filter[playletmod.FN.Playlet.IsRecommend] = true return filter, playletTodayRecommendSort() case homeconfigmod.SortTypeHot: return filter, playletHitSeriesSort() case homeconfigmod.SortTypeLatest: return filter, playletTodayUpdateSort() case homeconfigmod.SortTypePreview: filter[playletmod.FN.Playlet.Preview] = bson.M{"$exists": true, "$nin": []interface{}{"", nil}} return filter, playletTodayUpdateSort() case homeconfigmod.SortTypeCategory: if categoryId > 0 { filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{categoryId}} } return filter, playletTodayUpdateSort() default: return filter, playletTodayUpdateSort() } } // homeUpPlayletFilter 构建首页短剧上架过滤条件 func homeUpPlayletFilter(extra bson.M) bson.M { filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp} for key, value := range extra { filter[key] = value } return filter } // homePlayletCacheKey 获取app首页短剧缓存key func homePlayletCacheKey() string { return redisconst.AppPlayletHomeCacheDataKey(homePlayletCacheVersion() + ":" + homePlayletCacheSchema) } // homePlayletCacheVersion 获取app首页短剧缓存版本 func homePlayletCacheVersion() string { if redis.Client() == nil { return "0" } value, err := redis.Get(redisconst.AppPlayletHomeCacheVersionKey()) if err != nil { log.Warn("读取app首页短剧缓存版本失败", log.E(err)) return "0" } if value == nil || strings.TrimSpace(*value) == "" { return "0" } return strings.TrimSpace(*value) } // getHomePlayletCache 读取app首页短剧缓存 func getHomePlayletCache(cacheKey string) *proto.HomePlayletResponse { if redis.Client() == nil || cacheKey == "" { return nil } value, err := redis.Get(cacheKey) if err != nil { log.Warn("读取app首页短剧缓存失败", 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("解析app首页短剧缓存失败", log.E(err)) return nil } return &resp } // setHomePlayletCache 写入app首页短剧缓存 func setHomePlayletCache(cacheKey string, resp *proto.HomePlayletResponse) { if redis.Client() == nil || cacheKey == "" || resp == nil { return } data, err := json.Marshal(resp) if err != nil { log.Warn("序列化app首页短剧缓存失败", log.E(err)) return } if err = redis.Set(cacheKey, data, redisconst.AppPlayletHomeCacheExpire); err != nil { log.Warn("写入app首页短剧缓存失败", log.E(err)) } } // findHomePlaylets 查询首页分区或列表短剧数据 func findHomePlaylets(ctx context.Context, filter bson.M, sort bson.D, limit int64, extraOpts ...*options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr) { opt := options.Find(). SetProjection(playletHomeProjection). SetSort(sort). SetLimit(limit) for _, extra := range extraOpts { if extra == nil { continue } if extra.Skip != nil { opt.SetSkip(*extra.Skip) } } return playletmod.FindManyPlaylet(ctx, filter, opt) } // mergeHomePlayletLists 合并首页各分区短剧,便于后续批量查询题材和标签名称 func mergeHomePlayletLists(lists ...playletmod.PlayletList) playletmod.PlayletList { total := 0 for _, list := range lists { total += len(list) } result := make(playletmod.PlayletList, 0, total) for _, list := range lists { result = append(result, list...) } return result } // homeConfigsToHomeData 转换首页配置响应数据 func homeConfigsToHomeData(ctx context.Context, list homeconfigmod.HomeConfigList, configPlayletMap map[int64]playletmod.PlayletList, configVideoMap map[int64]videoinformationmod.VideoInformationList, configThemeMap map[int64][]homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.HomeConfigData { result := make([]*proto.HomeConfigData, 0, len(list)) for _, item := range list { if item == nil { continue } if item.SortType == homeconfigmod.SortTypeCategory { if item.Type == homeconfigmod.TypePlaylet && len(configPlayletMap[item.Id]) == 0 { continue } if item.Type == homeconfigmod.TypeVideo && len(configVideoMap[item.Id]) == 0 { continue } } data := &proto.HomeConfigData{ Id: item.Id, Name: item.Name, Slug: item.Slug, Desc: item.Desc, Type: item.Type, Rank: item.Rank, SortType: item.SortType, Status: item.Status, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, List: playletsToHomeCommonData(ctx, configPlayletMap[item.Id], themeNameMap, tagNameMap), VideoList: make([]*proto.VideoItemResp, 0), ThemeList: make([]*proto.HomeConfigThemeData, 0), SeoInfo: item.SeoInfo, } switch item.Type { case homeconfigmod.TypeVideo: data.List = make([]*proto.CommonData, 0) data.VideoList = videosToHomeConfigData(ctx, configVideoMap[item.Id]) case homeconfigmod.TypeTheme: data.List = make([]*proto.CommonData, 0) data.ThemeList = homeConfigThemesToHomeData(ctx, configThemeMap[item.Id], themeNameMap, tagNameMap) } result = append(result, data) } return result } // videosToHomeConfigData 转换首页配置视频响应数据 func videosToHomeConfigData(ctx context.Context, list videoinformationmod.VideoInformationList) []*proto.VideoItemResp { result := make([]*proto.VideoItemResp, 0, len(list)) imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) for _, item := range list { if item == nil { continue } result = append(result, &proto.VideoItemResp{ Id: item.Id, Slug: item.Slug, Title: item.Title, AddedTime: item.AddedTime, CreatorId: item.CreatorId, CreatorName: item.CreatorName, CoverImg: netutil.MergeURL(imgCdn, item.CoverImg), Keyworld: item.Keyworld, Tags: item.Tags, WatchTimes: item.Real.WatchTimes + item.Fake.WatchTimes, VideoTime: item.Video.VideoTime, }) } return result } // homeConfigThemesToHomeData 转换首页配置题材响应数据 func homeConfigThemesToHomeData(ctx context.Context, groups []homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.HomeConfigThemeData { result := make([]*proto.HomeConfigThemeData, 0, len(groups)) for _, group := range groups { if group.Theme == nil || len(group.Playlets) == 0 { continue } result = append(result, &proto.HomeConfigThemeData{ Id: group.Theme.Id, Name: group.Theme.Name, Slug: group.Theme.Slug, Icon: withImageDomain(ctx, group.Theme.Icon), Rank: group.Theme.Rank, PlayletCount: group.Theme.PlayletCount, List: playletsToHomeCommonData(ctx, group.Playlets, themeNameMap, tagNameMap), SeoInfo: buildPageSeoInfoFromValue(group.Theme.SeoInfo, group.Theme.Name, "", buildPlayletThemeListCanonicalURL(group.Theme.Slug, group.Theme.Id)), }) } return result } // playletTopicsToPageData 转换专题剧单分页响应 func playletTopicsToPageData(ctx context.Context, groups []homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.PlayletTopicPageItem { result := make([]*proto.PlayletTopicPageItem, 0, len(groups)) for _, group := range groups { if group.Theme == nil || len(group.Playlets) == 0 { continue } list := playletsToHomeCommonData(ctx, group.Playlets, themeNameMap, tagNameMap) result = append(result, &proto.PlayletTopicPageItem{ Id: group.Theme.Id, Name: group.Theme.Name, Slug: group.Theme.Slug, Desc: strings.TrimSpace(group.Theme.SeoInfo.Description), Icon: withImageDomain(ctx, group.Theme.Icon), Rank: group.Theme.Rank, PlayletCount: group.Theme.PlayletCount, Covers: playletTopicCovers(list), List: list, SeoInfo: buildPageSeoInfoFromValue(group.Theme.SeoInfo, group.Theme.Name, "", buildPlayletThemeListCanonicalURL(group.Theme.Slug, group.Theme.Id)), }) } return result } // playletTopicCovers 提取专题卡片封面 func playletTopicCovers(list []*proto.CommonData) []string { result := make([]string, 0, len(list)) for _, item := range list { if item == nil || strings.TrimSpace(item.Cover) == "" { continue } result = append(result, item.Cover) } return result } // buildHomeThemeNameMap 从缓存批量取首页短剧题材名称 func buildHomeThemeNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { themeIds := collectHomeIds(list, func(item *playletmod.Playlet) []int64 { return item.ThemeIds }) return playletcache.ThemeNameMap(themeIds), nil } // buildHomeTagNameMap 从缓存批量取首页短剧标签名称 func buildHomeTagNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { tagIds := collectHomeIds(list, func(item *playletmod.Playlet) []int64 { return item.TagIds }) return playletcache.TagNameMap(tagIds), nil } // collectHomeIds 汇总并去重首页短剧的题材或标签ID func collectHomeIds(list playletmod.PlayletList, pick func(item *playletmod.Playlet) []int64) []int64 { result := make([]int64, 0) seen := make(map[int64]struct{}) for _, item := range list { if item == nil { continue } for _, id := range pick(item) { if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} result = append(result, id) } } return result } // playletsToHomeCommonData 转换首页通用短剧响应数据 func playletsToHomeCommonData(ctx context.Context, list playletmod.PlayletList, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.CommonData { result := make([]*proto.CommonData, 0, len(list)) for _, item := range list { if item == nil { continue } if len(item.Description) > 0 { item.SeoInfo.Description = item.Description } result = append(result, &proto.CommonData{ Id: item.Id, Status: int32(item.Status), IsFeatured: item.IsFeatured, IsRecommend: item.IsRecommend, IsNew: isOnlineThisWeek(item.OnlineAt), OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)), ShowOnBanner: item.ShowOnBanner, Banner: withImageDomain(ctx, item.Banner), OnlineAt: item.OnlineAt, MediaCreatedAt: item.MediaCreatedAt, MediaUpdatedAt: item.MediaUpdatedAt, ThemeIds: item.ThemeIds, ThemeNames: namesByIds(item.ThemeIds, themeNameMap), ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), CategoryIds: item.CategoryIds, Title: item.Title, PlayName: item.PlayName, Description: item.Description, Slug: item.Slug, Desc: item.Desc, Anchor: item.Anchor, Cover: withImageDomain(ctx, item.DisplayCover()), HorizontalCover: withImageDomain(ctx, item.DisplayHorizontalCover()), Preview: item.Preview, TagIds: item.TagIds, TagNames: namesByIds(item.TagIds, tagNameMap), TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), TotalEpisode: item.TotalEpisode, UpdateStatus: item.UpdateStatus, CountBrowse: item.CountBrowse, HeatValue: item.HeatValue, CountCollect: item.CountCollect, CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, SeoInfo: item.SeoInfo, }) } return result } // slugsByIds 按ID顺序返回 slug func slugsByIds(ids []int64, slugMap map[int64]string) []string { result := make([]string, 0, len(ids)) for _, id := range ids { if slug := slugMap[id]; slug != "" { result = append(result, slug) } } return result } // namesByIds 按ID顺序返回名称 func namesByIds(ids []int64, nameMap map[int64]string) []string { result := make([]string, 0, len(ids)) for _, id := range ids { if name := nameMap[id]; name != "" { result = append(result, name) } } return result } // isOnlineThisWeek 判断短剧是否为本周内上架,用于app端展示“上新”标识。 func isOnlineThisWeek(onlineAt time.Time) bool { if onlineAt.IsZero() { return false } now := time.Now() weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 } weekStart := time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location()) return !onlineAt.Before(weekStart) && !onlineAt.After(now) } // commonDataValues 将短剧指针列表转换为响应值列表 func commonDataValues(list []*proto.CommonData) []proto.CommonData { result := make([]proto.CommonData, 0, len(list)) for _, item := range list { if item == nil { continue } result = append(result, *item) } return result } // playletAllListFilter 构建全部短剧分页过滤条件 func playletAllListFilter(req *proto.PlayletAllListReq) (bson.M, *srvdef.SrvErr) { filter := homeUpPlayletFilter(nil) if req.ThemeId > 0 { filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": []int64{req.ThemeId}} } switch req.UpdateStatus { case 0: case playletmod.UpdateStatusSerial, playletmod.UpdateStatusDone: filter[playletmod.FN.Playlet.UpdateStatus] = req.UpdateStatus default: return nil, srvdef.New(srvdef.ErrParamError, "更新状态无效", "更新状态无效") } return filter, nil } // playletAllListSort 构建全部短剧分页排序 func playletAllListSort(sortType int64) bson.D { switch sortType { case allListSortHot: return bson.D{ {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } case allListSortCollect: return bson.D{ {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } case allListSortLatest: fallthrough default: return bson.D{ {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } } // playletTopicDetailSort 构建专题详情短剧排序 func playletTopicDetailSort(sortType int64) bson.D { switch sortType { case topicDetailSortLatest: return bson.D{ {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } case topicDetailSortHot: return bson.D{ {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } case topicDetailSortEdit: fallthrough default: return bson.D{ {Key: playletmod.FN.Playlet.TopRank, Value: -1}, {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } } // playletSearchQuery 构建短剧ES搜索查询条件 func playletSearchQuery(keyword string, from int64, size int64) esmanager.M { return esmanager.M{ "from": from, "size": size, "query": esmanager.M{ "bool": esmanager.M{ "must": esmanager.A{ { "bool": esmanager.M{ "should": esmanager.A{ { "multi_match": esmanager.M{ "query": keyword, "fields": playletSearchFields(), "type": "phrase", "boost": 3, }, }, { "multi_match": esmanager.M{ "query": keyword, "fields": playletSearchFields(), "type": "best_fields", "operator": "and", }, }, }, "minimum_should_match": 1, }, }, }, "filter": esmanager.A{ { "term": esmanager.M{ esmod.FN.ESPlaylet.Status: int32(playletmod.StatusUp), }, }, }, }, }, "sort": esmanager.A{ { "_score": esmanager.M{"order": "desc"}, }, { esmod.FN.ESPlaylet.OnlineAt: esmanager.M{"order": "desc"}, }, { esmod.FN.ESPlaylet.Id: esmanager.M{"order": "desc"}, }, }, } } // playletSearchFields 返回短剧ES搜索字段和权重 func playletSearchFields() []string { // 剧名权重最高,题材名和标签名次之,简介兜底命中。 return []string{ esmod.FN.ESPlaylet.Title + "^3", esmod.FN.ESPlaylet.PlayName + "^3", esmod.FN.ESPlaylet.ThemeNames + "^2", esmod.FN.ESPlaylet.TagNames + "^2", esmod.FN.ESPlaylet.Desc, } } // esPlayletToCommonData 转换ES短剧文档为app通用短剧响应 func esPlayletToCommonData(ctx context.Context, item *esmod.ESPlaylet) proto.CommonData { return proto.CommonData{ Id: item.Id, Status: int32(item.Status), IsFeatured: item.IsFeatured, IsRecommend: item.IsRecommend, OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)), ShowOnBanner: item.ShowOnBanner, Banner: withImageDomain(ctx, item.Banner), OnlineAt: item.OnlineAt, ThemeIds: item.ThemeIds, ThemeNames: item.ThemeNames, ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), CategoryIds: item.CategoryIds, Title: item.Title, PlayName: item.PlayName, Description: item.Description, Slug: item.Slug, Desc: item.Desc, Anchor: item.Anchor, Cover: withImageDomain(ctx, item.Cover), TagIds: item.TagIds, TagNames: item.TagNames, TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), TotalEpisode: item.TotalEpisode, UpdateStatus: item.UpdateStatus, CountBrowse: item.CountBrowse, HeatValue: item.HeatValue, CountCollect: item.CountCollect, CountLike: item.CountLike, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, SeoInfo: item.SeoInfo, } } // playletRankSort 构建app短剧排行榜排序 func playletRankSort(sortOrder int64) bson.D { switch sortOrder { case rankHot: return bson.D{ {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } case rankNew: return bson.D{ {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } case rankCollect: return bson.D{ {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } default: return bson.D{ {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } } // normalizePlayletHotRankType 规范化热榜类型 func normalizePlayletHotRankType(rankType int64) int64 { if rankType == 0 { return rankHot } switch rankType { case rankHot, rankNew, rankCollect: return rankType default: return 0 } } // playletHotRankSort 构建热榜排序 func playletHotRankSort(rankType int64) bson.D { return playletRankSort(rankType) } // playletTodayRecommendSort 构建今日推荐短剧排序 func playletTodayRecommendSort() bson.D { return bson.D{ {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } // playletHitSeriesSort 构建热门好剧短剧排序 func playletHitSeriesSort() bson.D { return bson.D{ {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } // playletTodayUpdateSort 构建今日更新短剧排序 func playletTodayUpdateSort() bson.D { return bson.D{ {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } // operateTypeFilter 构建运营类型过滤条件;历史未写入字段按短剧处理。 func operateTypeFilter(operateType playletmod.OperateType) bson.M { if operateType == playletmod.OperateTypeComic { return bson.M{playletmod.FN.Playlet.OperateType: playletmod.OperateTypeComic} } return bson.M{playletmod.FN.Playlet.OperateType: bson.M{"$ne": playletmod.OperateTypeComic}} } // playletOperateListSort 构建运营类型列表排序 func playletOperateListSort(filterType int64) bson.D { switch filterType { case operateFilterHot: return bson.D{ {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } case operateFilterOriginal: return bson.D{ {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } default: return bson.D{ {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } } // normalizePlayletPage 规范化短剧分页参数 func normalizePlayletPage(pageNum, pageSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = defaultPageSize } if pageSize > maxPageSize { pageSize = maxPageSize } return pageNum, pageSize } // normalizePlayletGuessLikePage 规范化猜你喜欢分页参数 func normalizePlayletGuessLikePage(pageNum, pageSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = guessLikePageSize } if pageSize > maxPageSize { pageSize = maxPageSize } return pageNum, pageSize } // normalizePlayletHotRankPage 规范化热榜分页参数 func normalizePlayletHotRankPage(pageNum, pageSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = hotRankPageSize } if pageSize > maxPageSize { pageSize = maxPageSize } return pageNum, pageSize } // normalizePlayletTopicDetailPage 规范化专题详情分页参数 func normalizePlayletTopicDetailPage(pageNum, pageSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = topicDetailPageSize } if pageSize > maxPageSize { pageSize = maxPageSize } return pageNum, pageSize } // normalizeHomeConfigDetailPage 规范化首页配置详情分页参数 func normalizeHomeConfigDetailPage(pageNum, pageSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = homeConfigDetailSize } if pageSize > maxPageSize { pageSize = maxPageSize } return pageNum, pageSize } // normalizePlayletHistoryPage 规范化观看历史分页参数 func normalizePlayletHistoryPage(pageNum, pageSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = recommendPageSize } if pageSize > maxPageSize { pageSize = maxPageSize } return pageNum, pageSize } // withImageDomain 给图片相对路径拼接图片域名,海外 IP 使用 imgAppUsCdn。 func withImageDomain(ctx context.Context, path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } imageDomain := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) if imageDomain == "" { return path } return netutil.MergeURL(imageDomain, path) }