Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
+174
View File
@@ -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[:])
}
+109
View File
@@ -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)
}
+341
View File
@@ -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
}
+115
View File
@@ -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, &params); 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)
}
}
+274
View File
@@ -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
}
+84
View File
@@ -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)
}
}
+241
View File
@@ -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
}
+85
View File
@@ -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"`
}