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[:]) }