Files
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

342 lines
11 KiB
Go

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
}