Files
2026-10-26 16:28:14 +08:00

2590 lines
96 KiB
Go

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