package dramaser import ( "context" "fmt" "hash/fnv" "math/rand" "sort" "strings" "time" "91porn-server/app/appg" "91porn-server/app/service/mediacontentser" "91porn-server/app/service/mediaser" recommendqueue "91porn-server/common/shortrecommend" "91porn-server/models/cache/mediadata" "91porn-server/models/v/media_buy_record_mod" "91porn-server/models/v/mediacontentmod" "91porn-server/models/v/mediamod" "91porn-server/models/v/moduleconfmod" "91porn-server/models/v/vidmod" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo/options" ) const dramaFeedCandidateLimit = 500 func GetChannelConfig(now time.Time) (ChannelConfig, error) { modules, err := moduleconfmod.GetModuleConfByType(moduleconfmod.Drama) if err != nil { return ChannelConfig{}, err } active := make([]moduleconfmod.ModuleConf, 0, len(modules)) for _, module := range modules { if module.IsActiveAt(now) { active = append(active, module) } } sort.SliceStable(active, func(i, j int) bool { return active[i].SortNum > active[j].SortNum }) var aiModuleID, hotModuleID primitive.ObjectID for _, module := range active { name := strings.ToLower(strings.TrimSpace(module.ModuleName)) switch { case strings.Contains(name, "热门") || strings.Contains(name, "hot"): if hotModuleID.IsZero() { hotModuleID = module.ID } case strings.Contains(name, "ai"): if aiModuleID.IsZero() { aiModuleID = module.ID } } } if aiModuleID.IsZero() && len(active) > 0 { aiModuleID = active[0].ID } if hotModuleID.IsZero() && len(active) > 1 { hotModuleID = active[1].ID } return ChannelConfig{ BottomTabTitle: "短剧", DefaultTab: TabAIDrama, AdEnabled: false, Tabs: []ChannelTab{ {Key: TabLive, Title: "直播", Enabled: true, Sort: 1}, {Key: TabDouyin, Title: "抖音", Enabled: true, Sort: 2}, {Key: TabAIDrama, Title: "AI短剧", Enabled: !aiModuleID.IsZero(), Sort: 3, ModuleID: objectIDString(aiModuleID)}, {Key: TabHotDrama, Title: "热门短剧", Enabled: !hotModuleID.IsZero(), Sort: 4, ModuleID: objectIDString(hotModuleID)}, }, }, nil } func objectIDString(id primitive.ObjectID) string { if id.IsZero() { return "" } return id.Hex() } func GetList(uid uint64, req ListRequest) (ListResponse, error) { filter := bson.M{"mediaType": mediamod.MediaTypeDrama, "status": 1, "isDelete": false} sortBy := dramaListSort(req.SortType) total, err := mediamod.QueryAllCount(filter) if err != nil { return ListResponse{}, err } list, hasNext, err := mediadata.GetListFromCache(filter, req.Skip64(), req.Limit64(), sortBy) if err != nil { return ListResponse{}, err } return ListResponse{Total: total, HasNext: hasNext, List: mediaser.FillMedias(list, uid, true)}, nil } func dramaListSort(sortType int) bson.D { sortBy := bson.D{{Key: "sortCode", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}} switch sortType { case 2: sortBy = bson.D{{Key: "latestPublishedAt", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}} case 3: sortBy = bson.D{{Key: "countLike", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}} case 4: sortBy = bson.D{{Key: "countCollect", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}} } return sortBy } func GetFeed(ctx context.Context, uid uint64, pageSize uint64, now time.Time) (FeedResponse, error) { filter := bson.M{"mediaType": mediamod.MediaTypeDrama, "status": 1, "isDelete": false} candidates, _, err := mediadata.GetListFromCache( filter, 0, dramaFeedCandidateLimit, bson.D{{Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}, ) if err != nil { return FeedResponse{}, err } ordered := orderFeedCandidates(candidates, now) if len(ordered) == 0 { return FeedResponse{QueueVersion: "drama-" + recommendqueue.VersionAt(now), List: []FeedItem{}}, nil } queueIDs := make([]string, 0, len(ordered)) mediaByID := make(map[string]*mediamod.Media, len(ordered)) for _, media := range ordered { if media == nil { continue } id := media.ID.Hex() queueIDs = append(queueIDs, id) mediaByID[id] = media } queueVersion := dramaFeedQueueVersion(now, queueIDs) reservation, err := reserveDramaFeedPage( ctx, appg.Redis, uid, int(pageSize)*dramaFeedScanMultiplier, queueVersion, queueIDs, ) if err != nil { return FeedResponse{}, err } committed := false defer func() { if !committed { abortCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = abortDramaFeedPage(abortCtx, appg.Redis, uid, reservation) } }() reservedMedias := make([]*mediamod.Media, 0, len(reservation.IDs)) for _, id := range reservation.IDs { if media := mediaByID[id]; media != nil { reservedMedias = append(reservedMedias, media) } } hasCard, err := mediacontentser.ActiveDramaCardStatus(uid) if err != nil { return FeedResponse{}, err } mediaResponses := mediaser.FillMedias(reservedMedias, uid, true, hasCard) episodes, err := firstEpisodeMap(uid, reservedMedias, hasCard) if err != nil { return FeedResponse{}, err } responseByID := make(map[string]*mediamod.AppMediaBase, len(mediaResponses)) for _, media := range mediaResponses { if media != nil { responseByID[media.ID.Hex()] = media } } items := make([]FeedItem, 0, pageSize) scanned := 0 for _, id := range reservation.IDs { scanned++ media := responseByID[id] if media == nil { continue } episode, ok := episodes[media.ID] if !ok { continue } items = append(items, FeedItem{Media: media, Content: episode}) if len(items) >= int(pageSize) { break } } if err := commitDramaFeedPage(ctx, appg.Redis, uid, reservation, scanned); err != nil { return FeedResponse{}, err } committed = true return FeedResponse{ QueueVersion: "drama-" + reservation.Version, HasNext: reservation.Length > len(items), List: items, }, nil } // dramaFeedQueueVersion 将当天候选集快照写入版本。上架、下架导致候选集变化时, // 新请求会切换到独立队列;ID 排序后再计算,避免仅推荐顺序变化造成队列频繁重建。 func dramaFeedQueueVersion(now time.Time, queueIDs []string) string { ids := append([]string(nil), queueIDs...) sort.Strings(ids) h := fnv.New64a() for _, id := range ids { _, _ = h.Write([]byte(id)) _, _ = h.Write([]byte{0}) } return recommendqueue.VersionWithRevision(now, fmt.Sprintf("%016x", h.Sum64())) } func orderFeedCandidates(candidates []*mediamod.Media, now time.Time) []*mediamod.Media { recommended := make([]*mediamod.Media, 0) regular := make([]vidmod.RecommendCandidate, 0) mediaByID := make(map[primitive.ObjectID]*mediamod.Media, len(candidates)) for _, media := range candidates { if media == nil { continue } mediaByID[media.ID] = media candidate := vidmod.RecommendCandidate{ ID: media.ID, ReviewAt: dramaReviewAt(media), RecommendScore: dramaInteractionScore(media), } if media.SortCode > 0 || !media.Choice.IsZero() { recommended = append(recommended, media) continue } regular = append(regular, candidate) } rng := rand.New(rand.NewSource(dramaQueueSeed(now))) rng.Shuffle(len(recommended), func(i, j int) { recommended[i], recommended[j] = recommended[j], recommended[i] }) // 直接复用91P短视频队列的17条互动高分+3条24小时新内容组装算法。 regularIDs := recommendqueue.Assemble(regular, now) ordered := make([]*mediamod.Media, 0, len(candidates)) ordered = append(ordered, recommended...) ordered = appendDramaIDs(ordered, regularIDs, mediaByID) return ordered } func dramaQueueSeed(now time.Time) int64 { h := fnv.New64a() _, _ = h.Write([]byte("drama:" + recommendqueue.VersionAt(now))) return int64(h.Sum64()) } func dramaInteractionScore(media *mediamod.Media) int64 { if media == nil { return 0 } return vidmod.CalculateRecommendScore(media.CountLike, media.CountCollect, media.CountComment, media.CountShare) } func dramaReviewAt(media *mediamod.Media) time.Time { if media != nil && !media.LatestPublishedAt.IsZero() { return media.LatestPublishedAt } if media == nil { return time.Time{} } return media.CreatedAt } func appendDramaIDs( ordered []*mediamod.Media, ids []string, mediaByID map[primitive.ObjectID]*mediamod.Media, ) []*mediamod.Media { for _, rawID := range ids { id, err := primitive.ObjectIDFromHex(rawID) if err != nil { continue } if media := mediaByID[id]; media != nil { ordered = append(ordered, media) } } return ordered } func firstEpisodeMap(uid uint64, medias []*mediamod.Media, hasCard bool) (map[primitive.ObjectID]mediacontentser.AppMediaContent, error) { result := make(map[primitive.ObjectID]mediacontentser.AppMediaContent, len(medias)) if len(medias) == 0 { return result, nil } mediaIDs := make([]primitive.ObjectID, 0, len(medias)) mediaMap := make(map[primitive.ObjectID]*mediamod.Media, len(medias)) for _, media := range medias { if media != nil { mediaIDs = append(mediaIDs, media.ID) mediaMap[media.ID] = media } } contents, err := mediacontentmod.QueryAllList( bson.M{"mediaId": bson.M{"$in": mediaIDs}, "mediaType": mediamod.MediaTypeDrama, "episodeNumber": 1, "isActive": true, "isDelete": false}, options.Find().SetSort(bson.D{{Key: "createdAt", Value: 1}}), ) if err != nil { return nil, err } contentIDs := make([]primitive.ObjectID, 0, len(contents)) for _, content := range contents { if content != nil { contentIDs = append(contentIDs, content.ID) } } bought, err := media_buy_record_mod.IsContentBuyBatch(uid, contentIDs) if err != nil { return nil, err } for _, content := range contents { if content == nil || result[content.MediaID].ID != primitive.NilObjectID { continue } media := mediaMap[content.MediaID] if media == nil { continue } isFree, canPlay, accessType := mediacontentser.ResolveDramaAccess(*content, *media, bought[content.ID], hasCard) name := strings.TrimSpace(content.Name) if name == "" { name = fmt.Sprintf("第%d集", content.EpisodeNumber) } item := mediacontentser.AppMediaContent{ ID: content.ID, MediaID: content.MediaID, MediaType: content.MediaType, EpisodeNumber: content.EpisodeNumber, ListenPermission: content.ListenPermission, VideoUrl: content.VideoUrl, H265Url: appg.H265URLForApp(content.H265Url), AudioUrl: content.AudioUrl, PlayTime: content.PlayTime, Price: content.Price, Name: name, Cover: content.Cover, HasBuy: bought[content.ID], IsFree: isFree, HasDramaCard: hasCard, CanPlay: canPlay, AccessType: accessType, Ratio: content.Ratio, PreviewEnabled: content.PreviewEnabled, PreviewStart: content.PreviewStart, PreviewSeconds: content.PreviewSeconds, CreatedAt: content.CreatedAt, UpdateTime: content.UpdateTime, } if !item.CanPlay { if item.PreviewEnabled { item.PreviewVideoUrl, item.PreviewH265Url = item.VideoUrl, item.H265Url } item.VideoUrl, item.H265Url, item.AudioUrl = "", "", "" } result[content.MediaID] = item } return result, nil }