@@ -0,0 +1,174 @@
|
||||
package dramaser
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/models/v/eventmod"
|
||||
"91porn-server/models/v/mediacontentmod"
|
||||
"91porn-server/models/v/mediamod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const dramaEventDedupTTL = 30 * 24 * time.Hour
|
||||
|
||||
var analyticsEventNames = map[string]struct{}{
|
||||
"DRAMA_EXPOSURE": {}, "DRAMA_CLICK": {}, "DRAMA_PLAY_START": {},
|
||||
"DRAMA_PLAY_PROGRESS": {}, "DRAMA_PLAY_FINISH": {}, "DRAMA_PAYWALL_EXPOSURE": {},
|
||||
"DRAMA_UNLOCK_CLICK": {}, "DRAMA_CARD_CLICK": {}, "DRAMA_RECHARGE_SUCCESS": {},
|
||||
"DRAMA_UNLOCK_SUCCESS": {}, "DRAMA_CARD_PURCHASE_SUCCESS": {},
|
||||
}
|
||||
|
||||
var dramaScenes = map[string]struct{}{
|
||||
"ai_feed": {}, "hot_drama": {}, "detail": {}, "favorite": {},
|
||||
"like": {}, "history": {}, "purchased": {}, "share": {},
|
||||
}
|
||||
|
||||
func SaveEvents(uid uint64, requestID string, req EventsRequest) (EventsResponse, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" || len(requestID) > 128 {
|
||||
return EventsResponse{}, fmt.Errorf("X-Request-ID is required and must not exceed 128 characters")
|
||||
}
|
||||
if len(req.Events) == 0 || len(req.Events) > 50 {
|
||||
return EventsResponse{}, fmt.Errorf("events must contain 1 to 50 items")
|
||||
}
|
||||
response := EventsResponse{}
|
||||
mediaCache := make(map[primitive.ObjectID]mediamod.Media)
|
||||
contentCache := make(map[primitive.ObjectID]mediacontentmod.MediaContent)
|
||||
for _, event := range req.Events {
|
||||
if err := validateAnalyticsEvent(event); err != nil {
|
||||
return EventsResponse{}, err
|
||||
}
|
||||
if err := validateAnalyticsObjects(event, mediaCache, contentCache); err != nil {
|
||||
return EventsResponse{}, err
|
||||
}
|
||||
key := dramaIdempotencyKey("event", uid, event.EventID)
|
||||
acquired := true
|
||||
if appg.Redis != nil {
|
||||
var err error
|
||||
acquired, err = appg.Redis.SetNX(key, "1", dramaEventDedupTTL)
|
||||
if err != nil {
|
||||
return EventsResponse{}, err
|
||||
}
|
||||
} else {
|
||||
exists, err := eventmod.Exists(event.EventID)
|
||||
if err != nil {
|
||||
return EventsResponse{}, err
|
||||
}
|
||||
acquired = !exists
|
||||
}
|
||||
if !acquired {
|
||||
response.Duplicated++
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
UID uint64 `json:"uid"`
|
||||
AnalyticsEvent
|
||||
}{UID: uid, AnalyticsEvent: event})
|
||||
if err != nil {
|
||||
return EventsResponse{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
if err = eventmod.Insert(nil, &eventmod.Event{
|
||||
EventId: event.EventID, EventType: event.EventName, Data: string(payload),
|
||||
Status: eventmod.Default, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
if appg.Redis != nil {
|
||||
_, _ = appg.Redis.Del(key)
|
||||
}
|
||||
return EventsResponse{}, err
|
||||
}
|
||||
response.Accepted++
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func validateAnalyticsObjects(
|
||||
event AnalyticsEvent,
|
||||
mediaCache map[primitive.ObjectID]mediamod.Media,
|
||||
contentCache map[primitive.ObjectID]mediacontentmod.MediaContent,
|
||||
) error {
|
||||
media, ok := mediaCache[event.MediaID]
|
||||
if !ok {
|
||||
var err error
|
||||
media, err = mediamod.GetInfo(event.MediaID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mediaCache[event.MediaID] = media
|
||||
}
|
||||
if media.ID.IsZero() || media.MediaType != mediamod.MediaTypeDrama || media.Status != 1 || media.IsDelete {
|
||||
return fmt.Errorf("invalid drama event mediaId")
|
||||
}
|
||||
if event.ContentID.IsZero() {
|
||||
return nil
|
||||
}
|
||||
content, ok := contentCache[event.ContentID]
|
||||
if !ok {
|
||||
var err error
|
||||
content, err = mediacontentmod.GetInfo(event.ContentID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentCache[event.ContentID] = content
|
||||
}
|
||||
if content.MediaID != event.MediaID || content.MediaType != mediamod.MediaTypeDrama || content.IsDelete {
|
||||
return fmt.Errorf("event contentId does not belong to mediaId")
|
||||
}
|
||||
if event.EpisodeNumber > 0 && content.EpisodeNumber != event.EpisodeNumber {
|
||||
return fmt.Errorf("event episodeNumber does not match contentId")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnalyticsEvent(event AnalyticsEvent) error {
|
||||
event.EventID = strings.TrimSpace(event.EventID)
|
||||
if event.EventID == "" || len(event.EventID) > 128 {
|
||||
return fmt.Errorf("eventId is required and must not exceed 128 characters")
|
||||
}
|
||||
if _, ok := analyticsEventNames[event.EventName]; !ok {
|
||||
return fmt.Errorf("unsupported eventName: %s", event.EventName)
|
||||
}
|
||||
if _, ok := dramaScenes[event.Scene]; !ok {
|
||||
return fmt.Errorf("unsupported scene: %s", event.Scene)
|
||||
}
|
||||
if event.MediaID.IsZero() || event.OccurredAt.IsZero() || strings.TrimSpace(event.SessionID) == "" {
|
||||
return fmt.Errorf("sessionId, occurredAt and mediaId are required")
|
||||
}
|
||||
requireContent := event.EventName != "DRAMA_EXPOSURE" && event.EventName != "DRAMA_CLICK"
|
||||
if requireContent && event.ContentID.IsZero() {
|
||||
return fmt.Errorf("contentId is required for %s", event.EventName)
|
||||
}
|
||||
if event.EventName == "DRAMA_EXPOSURE" && event.Scene == "ai_feed" && (event.QueueVersion == "" || event.Position < 0) {
|
||||
return fmt.Errorf("queueVersion and position are required for feed exposure")
|
||||
}
|
||||
if strings.HasPrefix(event.EventName, "DRAMA_PLAY_") {
|
||||
if event.EpisodeNumber < 1 || (event.PlayMode != "feed" && event.PlayMode != "continuous") {
|
||||
return fmt.Errorf("episodeNumber and playMode are required for play events")
|
||||
}
|
||||
}
|
||||
if event.EventName == "DRAMA_PLAY_PROGRESS" && (event.Progress < 0 || event.Duration <= 0) {
|
||||
return fmt.Errorf("progressSeconds and durationSeconds are required for progress events")
|
||||
}
|
||||
if event.EventName == "DRAMA_PLAY_FINISH" && event.Duration <= 0 {
|
||||
return fmt.Errorf("durationSeconds is required for finish events")
|
||||
}
|
||||
if strings.Contains(event.EventName, "PAYWALL") || strings.Contains(event.EventName, "UNLOCK") ||
|
||||
strings.Contains(event.EventName, "CARD") || strings.Contains(event.EventName, "RECHARGE") {
|
||||
if strings.TrimSpace(event.CheckoutContextID) == "" {
|
||||
return fmt.Errorf("checkoutContextId is required for %s", event.EventName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dramaIdempotencyKey(scope string, uid uint64, id string) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%s", scope, uid, strings.TrimSpace(id))))
|
||||
return "drama:" + scope + ":dedup:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package dramaser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/common/db"
|
||||
"91porn-server/models/v/media_buy_record_mod"
|
||||
"91porn-server/models/v/mediacontentmod"
|
||||
"91porn-server/models/v/mediamod"
|
||||
"91porn-server/models/v/usermod"
|
||||
"91porn-server/models/v/walletmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDramaEntitlementRequired = errors.New("active drama entitlement is required")
|
||||
ErrDownloadResourceInvalid = errors.New("short drama episode is unavailable")
|
||||
)
|
||||
|
||||
const downloadTicketTTL = 6 * time.Hour
|
||||
|
||||
type DownloadAuthorizeRequest struct {
|
||||
MediaID primitive.ObjectID `json:"mediaId" binding:"required"`
|
||||
ContentID primitive.ObjectID `json:"contentId" binding:"required"`
|
||||
}
|
||||
|
||||
type DownloadAuthorizeResponse struct {
|
||||
MediaID primitive.ObjectID `json:"mediaId"`
|
||||
ContentID primitive.ObjectID `json:"contentId"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Name string `json:"name"`
|
||||
Cover string `json:"cover"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
H265DownloadURL string `json:"h265DownloadUrl"`
|
||||
MediaSize int64 `json:"mediaSize"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
RemainingDownloadCount int64 `json:"remainingDownloadCount"`
|
||||
Charged bool `json:"charged"`
|
||||
}
|
||||
|
||||
func AuthorizeDownload(ctx context.Context, uid uint64, requestID string, req DownloadAuthorizeRequest, now time.Time) (DownloadAuthorizeResponse, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if len(requestID) < 1 || len(requestID) > 128 {
|
||||
return DownloadAuthorizeResponse{}, errors.New("X-Request-ID长度必须为1到128")
|
||||
}
|
||||
media, err := mediamod.GetInfo(req.MediaID)
|
||||
if err != nil || media.MediaType != mediamod.MediaTypeDrama || media.Status != 1 || media.IsDelete {
|
||||
return DownloadAuthorizeResponse{}, ErrDownloadResourceInvalid
|
||||
}
|
||||
content, err := mediacontentmod.GetInfo(req.ContentID)
|
||||
if err != nil || content.MediaID != req.MediaID || content.MediaType != mediamod.MediaTypeDrama || content.Status != 1 || !content.IsActive || content.IsDelete {
|
||||
return DownloadAuthorizeResponse{}, ErrDownloadResourceInvalid
|
||||
}
|
||||
requestKey := downloadRequestKey(uid, requestID)
|
||||
fingerprint := req.MediaID.Hex() + ":" + req.ContentID.Hex()
|
||||
var wallet *walletmod.Wallet
|
||||
var charged bool
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
err = appg.VideoDB.TransCtx(ctx, func(t *db.MongoTool) error {
|
||||
user, userErr := usermod.FindUserByUIDTrans(t, uid)
|
||||
if userErr != nil {
|
||||
return userErr
|
||||
}
|
||||
bought := false
|
||||
if user != nil && !user.HasLocked && !user.DramaExpire.After(now) {
|
||||
bought, userErr = media_buy_record_mod.IsBuyWithTool(t, uid, req.MediaID, req.ContentID)
|
||||
if userErr != nil {
|
||||
return userErr
|
||||
}
|
||||
}
|
||||
if !canAuthorizeDramaDownload(user, bought, now) {
|
||||
return ErrDramaEntitlementRequired
|
||||
}
|
||||
wallet, charged, userErr = walletmod.AuthorizeDramaDownload(t, uid, requestKey, fingerprint, now)
|
||||
return userErr
|
||||
})
|
||||
if !errors.Is(err, walletmod.ErrDownloadAuthorizationRace) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return DownloadAuthorizeResponse{}, err
|
||||
}
|
||||
name := strings.TrimSpace(content.Name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("第%d集", content.EpisodeNumber)
|
||||
}
|
||||
return DownloadAuthorizeResponse{
|
||||
MediaID: req.MediaID, ContentID: req.ContentID, EpisodeNumber: content.EpisodeNumber,
|
||||
Name: name, Cover: content.Cover, DownloadURL: content.VideoUrl,
|
||||
H265DownloadURL: appg.H265URLForApp(content.H265Url), MediaSize: content.MediaSize,
|
||||
ExpiresAt: now.Add(downloadTicketTTL), RemainingDownloadCount: wallet.DownloadCount, Charged: charged,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadRequestKey(uid uint64, requestID string) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d:%s", uid, requestID))))
|
||||
}
|
||||
|
||||
func canAuthorizeDramaDownload(user *usermod.User, bought bool, now time.Time) bool {
|
||||
return user != nil && !user.HasLocked && (user.DramaExpire.After(now) || bought)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package dramaser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"91porn-server/models/v/mediamod"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
func TestDramaListSortMatchesMediaLibraryEnum(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sortType int
|
||||
want bson.D
|
||||
}{
|
||||
{name: "热门推荐", sortType: 1, want: bson.D{{Key: "sortCode", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}},
|
||||
{name: "最新上架", sortType: 2, want: bson.D{{Key: "latestPublishedAt", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}},
|
||||
{name: "最热", sortType: 3, want: bson.D{{Key: "countLike", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}},
|
||||
{name: "最多收藏", sortType: 4, want: bson.D{{Key: "countCollect", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", fmt.Sprintf("/?pageNumber=1&pageSize=20&sortType=%d", test.sortType), nil)
|
||||
var params ListRequest
|
||||
if err := binding.Query.Bind(request, ¶ms); err != nil {
|
||||
t.Fatalf("bind sortType=%d: %v", test.sortType, err)
|
||||
}
|
||||
if got := dramaListSort(params.SortType); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("dramaListSort(%d) = %#v, want %#v", test.sortType, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
legacyRequest := httptest.NewRequest("GET", "/?pageNumber=1&pageSize=20&sortType=7", nil)
|
||||
if err := binding.Query.Bind(legacyRequest, &ListRequest{}); err == nil {
|
||||
t.Fatal("bind legacy sortType=7 succeeded, want validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDramaFeedQueueVersionChangesWithCandidateSet(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 10, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
first := dramaFeedQueueVersion(now, []string{"media-b", "media-a"})
|
||||
sameSet := dramaFeedQueueVersion(now.Add(time.Hour), []string{"media-a", "media-b"})
|
||||
withNewDrama := dramaFeedQueueVersion(now.Add(time.Hour), []string{"media-a", "media-b", "media-c"})
|
||||
nextDay := dramaFeedQueueVersion(now.Add(24*time.Hour), []string{"media-a", "media-b"})
|
||||
|
||||
if first != sameSet {
|
||||
t.Fatalf("same-day candidate set produced different versions: %q != %q", first, sameSet)
|
||||
}
|
||||
if first == withNewDrama {
|
||||
t.Fatalf("new candidate did not change queue version: %q", first)
|
||||
}
|
||||
if first == nextDay {
|
||||
t.Fatalf("next day did not change queue version: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderFeedCandidatesPrioritizesRecommendedAndIsStable(t *testing.T) {
|
||||
now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
recommended := &mediamod.Media{ID: primitive.NewObjectID(), SortCode: 10, CreatedAt: now.Add(-time.Hour)}
|
||||
high := &mediamod.Media{ID: primitive.NewObjectID(), CountLike: 100, CreatedAt: now.Add(-48 * time.Hour)}
|
||||
recent := &mediamod.Media{ID: primitive.NewObjectID(), CreatedAt: now.Add(-time.Hour)}
|
||||
|
||||
candidates := []*mediamod.Media{high, recent, recommended}
|
||||
ordered := orderFeedCandidates(candidates, now)
|
||||
again := orderFeedCandidates(candidates, now.Add(time.Minute))
|
||||
if len(ordered) != 3 || len(again) != 3 {
|
||||
t.Fatalf("ordered lengths = %d/%d, want 3/3", len(ordered), len(again))
|
||||
}
|
||||
if ordered[0].ID != recommended.ID {
|
||||
t.Fatalf("first item = %s, want recommended %s", ordered[0].ID, recommended.ID)
|
||||
}
|
||||
for i := range ordered {
|
||||
if ordered[i].ID != again[i].ID {
|
||||
t.Fatalf("same-day queue changed at %d: %s != %s", i, ordered[i].ID, again[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAnalyticsEventRequiresCheckoutContextForPaywall(t *testing.T) {
|
||||
event := AnalyticsEvent{
|
||||
EventID: "event-1", EventName: "DRAMA_PAYWALL_EXPOSURE", SessionID: "session-1",
|
||||
OccurredAt: time.Now(), MediaID: primitive.NewObjectID(), ContentID: primitive.NewObjectID(),
|
||||
Scene: "detail",
|
||||
}
|
||||
if err := validateAnalyticsEvent(event); err == nil {
|
||||
t.Fatal("validateAnalyticsEvent() should reject missing checkoutContextId")
|
||||
}
|
||||
event.CheckoutContextID = "drama-checkout-test"
|
||||
if err := validateAnalyticsEvent(event); err != nil {
|
||||
t.Fatalf("validateAnalyticsEvent() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDramaInteractionScoreUses91PWeights(t *testing.T) {
|
||||
media := &mediamod.Media{
|
||||
CountLike: 1, CountCollect: 2, CountComment: 3, CountShare: 4, CountBrowse: 999,
|
||||
}
|
||||
if got, want := dramaInteractionScore(media), int64(34); got != want {
|
||||
t.Fatalf("dramaInteractionScore() = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectIDStringReturnsEmptyForZeroID(t *testing.T) {
|
||||
if got := objectIDString(primitive.NilObjectID); got != "" {
|
||||
t.Fatalf("objectIDString(zero) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package dramaser
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
redisutil "91porn-server/common/redis"
|
||||
)
|
||||
|
||||
const (
|
||||
dramaFeedScanMultiplier = 5
|
||||
dramaFeedQueueTTL = 72 * time.Hour
|
||||
dramaFeedLeaseTTL = 15 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
errDramaFeedQueueMissing = errors.New("drama feed queue is missing")
|
||||
errDramaFeedCursorBusy = errors.New("drama feed cursor is busy")
|
||||
)
|
||||
|
||||
type dramaFeedReservation struct {
|
||||
Version string
|
||||
Length int
|
||||
Offset int
|
||||
Reserved int
|
||||
Token string
|
||||
IDs []string
|
||||
}
|
||||
|
||||
type dramaFeedScriptClient interface {
|
||||
RunScriptContext(context.Context, *redisutil.Script, []string, ...interface{}) (interface{}, error)
|
||||
}
|
||||
|
||||
const publishDramaFeedQueueScriptSource = `
|
||||
if redis.call('EXISTS', KEYS[1]) == 1 then
|
||||
return 0
|
||||
end
|
||||
for i = 2, #ARGV do
|
||||
redis.call('RPUSH', KEYS[1], ARGV[i])
|
||||
end
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
return 1
|
||||
`
|
||||
|
||||
const reserveDramaFeedPageScriptSource = `
|
||||
local length = redis.call('LLEN', KEYS[1])
|
||||
if length <= 0 then
|
||||
return {'QUEUE_MISSING'}
|
||||
end
|
||||
if redis.call('EXISTS', KEYS[3]) == 1 then
|
||||
return {'BUSY'}
|
||||
end
|
||||
local offset = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0') % length
|
||||
local reserved = math.min(tonumber(ARGV[2]), length)
|
||||
redis.call('HSET', KEYS[3],
|
||||
'token', ARGV[3], 'length', length, 'offset', offset, 'reserved', reserved)
|
||||
redis.call('EXPIRE', KEYS[3], ARGV[4])
|
||||
local result = {'RESERVED', ARGV[5], tostring(length), tostring(offset), tostring(reserved), ARGV[3]}
|
||||
for i = 0, reserved - 1 do
|
||||
table.insert(result, redis.call('LINDEX', KEYS[1], (offset + i) % length))
|
||||
end
|
||||
return result
|
||||
`
|
||||
|
||||
const commitDramaFeedPageScriptSource = `
|
||||
local lease = redis.call('HMGET', KEYS[3], 'token', 'length', 'offset', 'reserved')
|
||||
if not lease[1] then return -1 end
|
||||
if lease[1] ~= ARGV[2] or tonumber(lease[2]) ~= tonumber(ARGV[3]) or
|
||||
tonumber(lease[3]) ~= tonumber(ARGV[4]) or tonumber(lease[4]) ~= tonumber(ARGV[5]) then
|
||||
return -2
|
||||
end
|
||||
local length = redis.call('LLEN', KEYS[1])
|
||||
if length <= 0 or length ~= tonumber(ARGV[3]) then return -3 end
|
||||
local consumed = tonumber(ARGV[6])
|
||||
if not consumed or consumed <= 0 or consumed > tonumber(ARGV[5]) then return -2 end
|
||||
local currentOffset = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0') % length
|
||||
if currentOffset ~= tonumber(ARGV[4]) then return -2 end
|
||||
redis.call('HSET', KEYS[2], ARGV[1], (currentOffset + consumed) % length)
|
||||
redis.call('EXPIRE', KEYS[2], ARGV[7])
|
||||
redis.call('DEL', KEYS[3])
|
||||
return 1
|
||||
`
|
||||
|
||||
const abortDramaFeedPageScriptSource = `
|
||||
if redis.call('HGET', KEYS[1], 'token') ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
return redis.call('DEL', KEYS[1])
|
||||
`
|
||||
|
||||
var (
|
||||
publishDramaFeedQueueScript = redisutil.NewScript(publishDramaFeedQueueScriptSource)
|
||||
reserveDramaFeedPageScript = redisutil.NewScript(reserveDramaFeedPageScriptSource)
|
||||
commitDramaFeedPageScript = redisutil.NewScript(commitDramaFeedPageScriptSource)
|
||||
abortDramaFeedPageScript = redisutil.NewScript(abortDramaFeedPageScriptSource)
|
||||
)
|
||||
|
||||
func dramaFeedQueueKey(version string) string {
|
||||
return "recommend:drama:queue:" + version
|
||||
}
|
||||
|
||||
func dramaFeedOffsetKey(version string) string {
|
||||
return "recommend:drama:offset:" + version
|
||||
}
|
||||
|
||||
func dramaFeedLeaseKey(version string, uid uint64) string {
|
||||
return "recommend:drama:reservation:" + version + ":" + strconv.FormatUint(uid, 10)
|
||||
}
|
||||
|
||||
func reserveDramaFeedPage(
|
||||
ctx context.Context,
|
||||
client dramaFeedScriptClient,
|
||||
uid uint64,
|
||||
size int,
|
||||
version string,
|
||||
queueIDs []string,
|
||||
) (dramaFeedReservation, error) {
|
||||
if ctx == nil {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed context must not be nil")
|
||||
}
|
||||
if client == nil {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed Redis client must not be nil")
|
||||
}
|
||||
if uid == 0 || size <= 0 || version == "" || len(queueIDs) == 0 {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed reservation arguments are invalid")
|
||||
}
|
||||
token, err := newDramaFeedToken()
|
||||
if err != nil {
|
||||
return dramaFeedReservation{}, err
|
||||
}
|
||||
reservation, err := runReserveDramaFeedPage(ctx, client, uid, size, version, token)
|
||||
if !errors.Is(err, errDramaFeedQueueMissing) {
|
||||
return reservation, err
|
||||
}
|
||||
args := make([]interface{}, 1, len(queueIDs)+1)
|
||||
args[0] = int64(dramaFeedQueueTTL / time.Second)
|
||||
for _, id := range queueIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
if _, err = client.RunScriptContext(
|
||||
ctx, publishDramaFeedQueueScript, []string{dramaFeedQueueKey(version)}, args...,
|
||||
); err != nil {
|
||||
return dramaFeedReservation{}, err
|
||||
}
|
||||
return runReserveDramaFeedPage(ctx, client, uid, size, version, token)
|
||||
}
|
||||
|
||||
func runReserveDramaFeedPage(
|
||||
ctx context.Context,
|
||||
client dramaFeedScriptClient,
|
||||
uid uint64,
|
||||
size int,
|
||||
version, token string,
|
||||
) (dramaFeedReservation, error) {
|
||||
raw, err := client.RunScriptContext(
|
||||
ctx,
|
||||
reserveDramaFeedPageScript,
|
||||
[]string{
|
||||
dramaFeedQueueKey(version),
|
||||
dramaFeedOffsetKey(version),
|
||||
dramaFeedLeaseKey(version, uid),
|
||||
},
|
||||
strconv.FormatUint(uid, 10),
|
||||
size,
|
||||
token,
|
||||
int64(dramaFeedLeaseTTL/time.Second),
|
||||
version,
|
||||
)
|
||||
if err != nil {
|
||||
return dramaFeedReservation{}, err
|
||||
}
|
||||
return parseDramaFeedReservation(raw)
|
||||
}
|
||||
|
||||
func parseDramaFeedReservation(raw interface{}) (dramaFeedReservation, error) {
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed reserve returned malformed result")
|
||||
}
|
||||
switch fmt.Sprint(items[0]) {
|
||||
case "QUEUE_MISSING":
|
||||
return dramaFeedReservation{}, errDramaFeedQueueMissing
|
||||
case "BUSY":
|
||||
return dramaFeedReservation{}, errDramaFeedCursorBusy
|
||||
case "RESERVED":
|
||||
default:
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed reserve returned unknown status")
|
||||
}
|
||||
if len(items) < 7 {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed reserve returned incomplete result")
|
||||
}
|
||||
result := dramaFeedReservation{Version: fmt.Sprint(items[1]), Token: fmt.Sprint(items[5])}
|
||||
var err error
|
||||
if result.Length, err = strconv.Atoi(fmt.Sprint(items[2])); err != nil {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed reserve returned invalid length: %w", err)
|
||||
}
|
||||
if result.Offset, err = strconv.Atoi(fmt.Sprint(items[3])); err != nil {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed reserve returned invalid offset: %w", err)
|
||||
}
|
||||
if result.Reserved, err = strconv.Atoi(fmt.Sprint(items[4])); err != nil {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed reserve returned invalid size: %w", err)
|
||||
}
|
||||
if result.Version == "" || result.Token == "" || result.Length <= 0 ||
|
||||
result.Offset < 0 || result.Offset >= result.Length || result.Reserved <= 0 ||
|
||||
result.Reserved > result.Length || len(items)-6 != result.Reserved {
|
||||
return dramaFeedReservation{}, fmt.Errorf("drama feed reserve returned invalid bounds")
|
||||
}
|
||||
for _, item := range items[6:] {
|
||||
result.IDs = append(result.IDs, fmt.Sprint(item))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func commitDramaFeedPage(
|
||||
ctx context.Context,
|
||||
client dramaFeedScriptClient,
|
||||
uid uint64,
|
||||
reservation dramaFeedReservation,
|
||||
consumed int,
|
||||
) error {
|
||||
if client == nil {
|
||||
return fmt.Errorf("drama feed Redis client must not be nil")
|
||||
}
|
||||
raw, err := client.RunScriptContext(
|
||||
ctx,
|
||||
commitDramaFeedPageScript,
|
||||
[]string{
|
||||
dramaFeedQueueKey(reservation.Version),
|
||||
dramaFeedOffsetKey(reservation.Version),
|
||||
dramaFeedLeaseKey(reservation.Version, uid),
|
||||
},
|
||||
strconv.FormatUint(uid, 10), reservation.Token, reservation.Length,
|
||||
reservation.Offset, reservation.Reserved, consumed,
|
||||
int64(dramaFeedQueueTTL/time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fmt.Sprint(raw) != "1" {
|
||||
return fmt.Errorf("drama feed commit rejected: %v", raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func abortDramaFeedPage(
|
||||
ctx context.Context,
|
||||
client dramaFeedScriptClient,
|
||||
uid uint64,
|
||||
reservation dramaFeedReservation,
|
||||
) error {
|
||||
if client == nil || reservation.Version == "" || reservation.Token == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := client.RunScriptContext(
|
||||
ctx,
|
||||
abortDramaFeedPageScript,
|
||||
[]string{dramaFeedLeaseKey(reservation.Version, uid)},
|
||||
reservation.Token,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func newDramaFeedToken() (string, error) {
|
||||
value := make([]byte, 16)
|
||||
if _, err := cryptorand.Read(value); err != nil {
|
||||
return "", fmt.Errorf("generate drama feed reservation token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package dramaser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
redisutil "91porn-server/common/redis"
|
||||
)
|
||||
|
||||
type fakeDramaFeedRedis struct {
|
||||
calls int
|
||||
run func(int, *redisutil.Script, []string, []interface{}) (interface{}, error)
|
||||
}
|
||||
|
||||
func (f *fakeDramaFeedRedis) RunScriptContext(
|
||||
_ context.Context,
|
||||
script *redisutil.Script,
|
||||
keys []string,
|
||||
args ...interface{},
|
||||
) (interface{}, error) {
|
||||
f.calls++
|
||||
return f.run(f.calls, script, keys, args)
|
||||
}
|
||||
|
||||
func TestReserveDramaFeedPagePublishesOnceAndReturnsRingSegment(t *testing.T) {
|
||||
ids := []string{"a", "b", "c", "d"}
|
||||
client := &fakeDramaFeedRedis{}
|
||||
client.run = func(call int, script *redisutil.Script, keys []string, args []interface{}) (interface{}, error) {
|
||||
switch call {
|
||||
case 1:
|
||||
if script != reserveDramaFeedPageScript {
|
||||
t.Fatal("first call should reserve existing queue")
|
||||
}
|
||||
return []interface{}{"QUEUE_MISSING"}, nil
|
||||
case 2:
|
||||
if script != publishDramaFeedQueueScript || keys[0] != "recommend:drama:queue:20260826" {
|
||||
t.Fatalf("unexpected publish call: keys=%v", keys)
|
||||
}
|
||||
if len(args) != len(ids)+1 {
|
||||
t.Fatalf("publish args = %d, want %d", len(args), len(ids)+1)
|
||||
}
|
||||
return int64(1), nil
|
||||
case 3:
|
||||
if script != reserveDramaFeedPageScript {
|
||||
t.Fatal("third call should reserve published queue")
|
||||
}
|
||||
token := fmt.Sprint(args[2])
|
||||
return []interface{}{"RESERVED", "20260826", "4", "2", "2", token, "c", "d"}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected Redis call %d", call)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
reservation, err := reserveDramaFeedPage(context.Background(), client, 7, 2, "20260826", ids)
|
||||
if err != nil {
|
||||
t.Fatalf("reserveDramaFeedPage() error = %v", err)
|
||||
}
|
||||
if reservation.Offset != 2 || reservation.Reserved != 2 ||
|
||||
fmt.Sprint(reservation.IDs) != "[c d]" {
|
||||
t.Fatalf("reservation = %+v", reservation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitDramaFeedPageAdvancesOnlyConsumedPrefix(t *testing.T) {
|
||||
reservation := dramaFeedReservation{
|
||||
Version: "20260826", Length: 20, Offset: 5, Reserved: 10,
|
||||
Token: "token", IDs: []string{"a", "b"},
|
||||
}
|
||||
client := &fakeDramaFeedRedis{}
|
||||
client.run = func(call int, script *redisutil.Script, keys []string, args []interface{}) (interface{}, error) {
|
||||
if script != commitDramaFeedPageScript {
|
||||
t.Fatal("expected commit script")
|
||||
}
|
||||
if keys[1] != "recommend:drama:offset:20260826" || fmt.Sprint(args[5]) != "4" {
|
||||
t.Fatalf("commit keys/args mismatch: keys=%v args=%v", keys, args)
|
||||
}
|
||||
return int64(1), nil
|
||||
}
|
||||
if err := commitDramaFeedPage(context.Background(), client, 7, reservation, 4); err != nil {
|
||||
t.Fatalf("commitDramaFeedPage() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package dramaser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/app/service/mediaser"
|
||||
"91porn-server/common/dramatopic"
|
||||
"91porn-server/common/log"
|
||||
"91porn-server/models/commod"
|
||||
"91porn-server/models/v/mediamod"
|
||||
"91porn-server/models/v/modulesectionmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const everyoneLikesCacheTTL = 30 * time.Second
|
||||
|
||||
type TopicSummary struct {
|
||||
TopicID string `json:"topicId"`
|
||||
Name string `json:"name"`
|
||||
TopicType string `json:"topicType"`
|
||||
SystemKey string `json:"systemKey"`
|
||||
Sort int `json:"sort"`
|
||||
WorkCount int64 `json:"workCount"`
|
||||
}
|
||||
|
||||
type TopicListResponse struct {
|
||||
List []TopicSummary `json:"list"`
|
||||
}
|
||||
|
||||
type TopicWorksRequest struct {
|
||||
TopicID string `form:"topicId" json:"topicId" binding:"required"`
|
||||
commod.Page
|
||||
}
|
||||
|
||||
type TopicWorksResponse struct {
|
||||
Topic TopicSummary `json:"topic"`
|
||||
Total int64 `json:"total"`
|
||||
HasNext bool `json:"hasNext"`
|
||||
List []*mediamod.AppMediaBase `json:"list"`
|
||||
}
|
||||
|
||||
func GetTopics(now time.Time) (TopicListResponse, error) {
|
||||
moduleID, ok, err := dramatopic.ModuleID(now)
|
||||
if err != nil {
|
||||
return TopicListResponse{}, err
|
||||
}
|
||||
total, err := mediamod.QueryAllCount(activeDramaFilter())
|
||||
if err != nil {
|
||||
return TopicListResponse{}, err
|
||||
}
|
||||
sections := make([]modulesectionmod.Section, 0)
|
||||
if ok {
|
||||
sections, err = modulesectionmod.ListBySubModule(moduleID, 1000)
|
||||
if err != nil {
|
||||
return TopicListResponse{}, err
|
||||
}
|
||||
}
|
||||
sectionIDs := make([]primitive.ObjectID, 0, len(sections))
|
||||
for _, section := range sections {
|
||||
if section.Status != nil && *section.Status == 1 {
|
||||
sectionIDs = append(sectionIDs, section.ID)
|
||||
}
|
||||
}
|
||||
counts, err := mediamod.CountActiveDramaBySectionIDs(sectionIDs)
|
||||
if err != nil {
|
||||
return TopicListResponse{}, err
|
||||
}
|
||||
items := make([]topicSortItem, 0, 3+len(sections))
|
||||
for _, system := range dramatopic.SystemTopics() {
|
||||
items = append(items, topicSortItem{summary: TopicSummary{
|
||||
TopicID: system.ID, Name: system.Name, TopicType: dramatopic.TypeSystem,
|
||||
SystemKey: system.SystemKey, Sort: system.Sort, WorkCount: total,
|
||||
}, systemOrder: system.TieOrder})
|
||||
}
|
||||
for _, section := range sections {
|
||||
if section.Status == nil || *section.Status != 1 {
|
||||
continue
|
||||
}
|
||||
items = append(items, topicSortItem{summary: TopicSummary{
|
||||
TopicID: section.ID.Hex(), Name: section.SectionName, TopicType: dramatopic.TypeCustom,
|
||||
Sort: valueOrZero(section.Sort), WorkCount: counts[section.ID],
|
||||
}, updatedAt: section.UpdatedAt})
|
||||
}
|
||||
sortTopicItems(items)
|
||||
response := TopicListResponse{List: make([]TopicSummary, 0, len(items))}
|
||||
for _, item := range items {
|
||||
response.List = append(response.List, item.summary)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type topicSortItem struct {
|
||||
summary TopicSummary
|
||||
systemOrder int
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
func sortTopicItems(items []topicSortItem) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].summary.Sort != items[j].summary.Sort {
|
||||
return items[i].summary.Sort > items[j].summary.Sort
|
||||
}
|
||||
if items[i].systemOrder != items[j].systemOrder {
|
||||
return items[i].systemOrder > items[j].systemOrder
|
||||
}
|
||||
return items[i].updatedAt.After(items[j].updatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func GetTopicWorks(uid uint64, req TopicWorksRequest, now time.Time) (TopicWorksResponse, error) {
|
||||
if system, ok := dramatopic.FindSystem(req.TopicID); ok {
|
||||
return getSystemTopicWorks(uid, req, system)
|
||||
}
|
||||
moduleID, ok, err := dramatopic.ModuleID(now)
|
||||
if err != nil || !ok {
|
||||
if err == nil {
|
||||
err = errors.New("短剧专题模块未配置")
|
||||
}
|
||||
return TopicWorksResponse{}, err
|
||||
}
|
||||
topicID, err := primitive.ObjectIDFromHex(req.TopicID)
|
||||
if err != nil {
|
||||
return TopicWorksResponse{}, errors.New("无效的专题ID")
|
||||
}
|
||||
section, err := modulesectionmod.GetBySectionByID(topicID)
|
||||
if err != nil || section.SubModuleID != moduleID || section.DeletedAt != nil || section.Status == nil || *section.Status != 1 {
|
||||
if err == nil {
|
||||
err = errors.New("专题不存在或已停用")
|
||||
}
|
||||
return TopicWorksResponse{}, err
|
||||
}
|
||||
filter := activeDramaFilter()
|
||||
filter["sId"] = topicID
|
||||
total, err := mediamod.QueryAllCount(filter)
|
||||
if err != nil {
|
||||
return TopicWorksResponse{}, err
|
||||
}
|
||||
list, err := mediamod.QueryAllList(filter, options.Find().
|
||||
SetSkip(req.Skip64()).SetLimit(req.Limit64()+1).
|
||||
SetSort(bson.D{{Key: "sectionSort", Value: -1}, {Key: "latestPublishedAt", Value: -1}, {Key: "_id", Value: -1}}))
|
||||
if err != nil {
|
||||
return TopicWorksResponse{}, err
|
||||
}
|
||||
hasNext := len(list) > int(req.PageSize)
|
||||
if hasNext {
|
||||
list = list[:req.PageSize]
|
||||
}
|
||||
return TopicWorksResponse{
|
||||
Topic: TopicSummary{TopicID: req.TopicID, Name: section.SectionName, TopicType: dramatopic.TypeCustom, Sort: valueOrZero(section.Sort), WorkCount: total},
|
||||
Total: total, HasNext: hasNext, List: fillTopicMedias(list, uid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getSystemTopicWorks(uid uint64, req TopicWorksRequest, topic dramatopic.SystemTopic) (TopicWorksResponse, error) {
|
||||
filter := activeDramaFilter()
|
||||
total, err := mediamod.QueryAllCount(filter)
|
||||
if err != nil {
|
||||
return TopicWorksResponse{}, err
|
||||
}
|
||||
var list []*mediamod.Media
|
||||
var hasNext bool
|
||||
if topic.ID == dramatopic.IDEveryone {
|
||||
list, err = listEveryoneLikes(req.Skip64(), req.Limit64()+1)
|
||||
if err != nil {
|
||||
return TopicWorksResponse{}, err
|
||||
}
|
||||
hasNext = len(list) > int(req.PageSize)
|
||||
if hasNext {
|
||||
list = list[:req.PageSize]
|
||||
}
|
||||
} else {
|
||||
sortBy := bson.D{{Key: "sortCode", Value: -1}, {Key: "latestPublishedAt", Value: -1}, {Key: "_id", Value: -1}}
|
||||
if topic.ID == dramatopic.IDLatest {
|
||||
sortBy = bson.D{{Key: "latestPublishedAt", Value: -1}, {Key: "createdAt", Value: -1}, {Key: "_id", Value: -1}}
|
||||
}
|
||||
list, err = mediamod.QueryAllList(filter, options.Find().SetSkip(req.Skip64()).SetLimit(req.Limit64()+1).SetSort(sortBy))
|
||||
if err != nil {
|
||||
return TopicWorksResponse{}, err
|
||||
}
|
||||
hasNext = len(list) > int(req.PageSize)
|
||||
if hasNext {
|
||||
list = list[:req.PageSize]
|
||||
}
|
||||
}
|
||||
return TopicWorksResponse{
|
||||
Topic: TopicSummary{TopicID: topic.ID, Name: topic.Name, TopicType: dramatopic.TypeSystem, SystemKey: topic.SystemKey, Sort: topic.Sort, WorkCount: total},
|
||||
Total: total, HasNext: hasNext, List: fillTopicMedias(list, uid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fillTopicMedias(list []*mediamod.Media, uid uint64) []*mediamod.AppMediaBase {
|
||||
filled := mediaser.FillMedias(list, uid, true)
|
||||
if filled == nil {
|
||||
return []*mediamod.AppMediaBase{}
|
||||
}
|
||||
return filled
|
||||
}
|
||||
|
||||
func listEveryoneLikes(skip, limit int64) ([]*mediamod.Media, error) {
|
||||
key := fmt.Sprintf("drama:topic:everyone-likes:v1:%d:%d", skip, limit)
|
||||
if appg.Redis != nil {
|
||||
cached, err := appg.Redis.Get(key)
|
||||
if err == nil && cached != nil {
|
||||
var list []*mediamod.Media
|
||||
if jsonErr := json.Unmarshal([]byte(*cached), &list); jsonErr == nil {
|
||||
return list, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
list, err := mediamod.ListActiveDramaByInteractionScore(skip, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if appg.Redis != nil {
|
||||
if data, marshalErr := json.Marshal(list); marshalErr == nil {
|
||||
if cacheErr := appg.Redis.Set(key, data, everyoneLikesCacheTTL); cacheErr != nil {
|
||||
log.Warn("缓存大家爱看短剧列表失败", log.E(cacheErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func activeDramaFilter() bson.M {
|
||||
return bson.M{"mediaType": mediamod.MediaTypeDrama, "status": 1, "isDelete": false}
|
||||
}
|
||||
|
||||
func valueOrZero(value *int) int {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package dramaser
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"91porn-server/app/service/mediacontentser"
|
||||
"91porn-server/models/commod"
|
||||
"91porn-server/models/v/mediamod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const (
|
||||
TabLive = "live"
|
||||
TabDouyin = "douyin"
|
||||
TabAIDrama = "ai_drama"
|
||||
TabHotDrama = "hot_drama"
|
||||
)
|
||||
|
||||
type ChannelTab struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sort int `json:"sort"`
|
||||
ModuleID string `json:"moduleId"`
|
||||
}
|
||||
|
||||
type ChannelConfig struct {
|
||||
BottomTabTitle string `json:"bottomTabTitle"`
|
||||
DefaultTab string `json:"defaultTab"`
|
||||
AdEnabled bool `json:"adEnabled"`
|
||||
Tabs []ChannelTab `json:"tabs"`
|
||||
}
|
||||
|
||||
type FeedRequest struct {
|
||||
PageSize uint64 `form:"pageSize" json:"pageSize" binding:"required,min=1,max=50"`
|
||||
}
|
||||
|
||||
type FeedItem struct {
|
||||
Media *mediamod.AppMediaBase `json:"media"`
|
||||
Content mediacontentser.AppMediaContent `json:"content"`
|
||||
}
|
||||
|
||||
type FeedResponse struct {
|
||||
QueueVersion string `json:"queueVersion"`
|
||||
HasNext bool `json:"hasNext"`
|
||||
List []FeedItem `json:"list"`
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
commod.Page
|
||||
SortType int `form:"sortType" json:"sortType" binding:"required,oneof=1 2 3 4"`
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
HasNext bool `json:"hasNext"`
|
||||
List []*mediamod.AppMediaBase `json:"list"`
|
||||
}
|
||||
|
||||
type AnalyticsEvent struct {
|
||||
EventID string `json:"eventId" binding:"required,max=128"`
|
||||
EventName string `json:"eventName" binding:"required"`
|
||||
SessionID string `json:"sessionId" binding:"required,max=128"`
|
||||
OccurredAt time.Time `json:"occurredAt" binding:"required"`
|
||||
MediaID primitive.ObjectID `json:"mediaId" binding:"required"`
|
||||
ContentID primitive.ObjectID `json:"contentId"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Scene string `json:"scene" binding:"required"`
|
||||
PlayMode string `json:"playMode"`
|
||||
Progress int `json:"progressSeconds"`
|
||||
Duration int `json:"durationSeconds"`
|
||||
QueueVersion string `json:"queueVersion"`
|
||||
Position int `json:"position"`
|
||||
CheckoutContextID string `json:"checkoutContextId"`
|
||||
}
|
||||
|
||||
type EventsRequest struct {
|
||||
Events []AnalyticsEvent `json:"events" binding:"required,min=1,max=50,dive"`
|
||||
}
|
||||
|
||||
type EventsResponse struct {
|
||||
Accepted int `json:"accepted"`
|
||||
Duplicated int `json:"duplicated"`
|
||||
}
|
||||
Reference in New Issue
Block a user