349 lines
11 KiB
Go
349 lines
11 KiB
Go
package vipcardexperimentser
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"91porn-server/app/proto"
|
|
"91porn-server/models/v/vipcardexperimentmod"
|
|
|
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
|
)
|
|
|
|
const maxEventsPerRequest = 50
|
|
|
|
type EventInput struct {
|
|
EventID string `json:"eventId"`
|
|
EventName string `json:"eventName"`
|
|
SessionID string `json:"sessionId"`
|
|
OccurredAt time.Time `json:"occurredAt"`
|
|
ExperimentID string `json:"experimentId"`
|
|
Variant string `json:"variant"`
|
|
ProductID primitive.ObjectID `json:"productId"`
|
|
}
|
|
|
|
type EventsRequest struct {
|
|
Events []EventInput `json:"events" binding:"required"`
|
|
}
|
|
|
|
type EventsResponse struct {
|
|
Accepted int `json:"accepted"`
|
|
Duplicated int `json:"duplicated"`
|
|
}
|
|
|
|
// ApplyToProductResponse applies the active experiment to the normal product response.
|
|
// No experiment or an unusable configuration keeps the legacy response unchanged.
|
|
func ApplyToProductResponse(uid uint64, response *proto.ProductRes, now time.Time) error {
|
|
if response == nil {
|
|
return nil
|
|
}
|
|
response.ExperimentStatus = vipcardexperimentmod.StatusDisabled
|
|
if uid == 0 {
|
|
return nil
|
|
}
|
|
experiment, err := vipcardexperimentmod.Current(now)
|
|
if err != nil || experiment == nil {
|
|
return err
|
|
}
|
|
variant := experiment.Assign(uid)
|
|
config, ok := experiment.ConfigFor(variant)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if !applyExperiment(response, experiment.ExperimentID, variant, config) {
|
|
return nil
|
|
}
|
|
otherConfig := experiment.VariantA
|
|
if variant == vipcardexperimentmod.VariantA {
|
|
otherConfig = experiment.VariantB
|
|
}
|
|
filterOtherVariantProducts(response, config.ProductIDs, otherConfig.ProductIDs)
|
|
return nil
|
|
}
|
|
|
|
func applyExperiment(
|
|
response *proto.ProductRes,
|
|
experimentID string,
|
|
variant string,
|
|
config vipcardexperimentmod.VariantConfig,
|
|
) bool {
|
|
if !applyVariant(response, config) {
|
|
return false
|
|
}
|
|
response.ExperimentID = experimentID
|
|
response.ExperimentStatus = vipcardexperimentmod.StatusActive
|
|
response.Variant = variant
|
|
response.SkinKey = config.SkinKey
|
|
if !config.DefaultProductID.IsZero() {
|
|
response.DefaultProductID = config.DefaultProductID.Hex()
|
|
}
|
|
response.UIConfig = toResponseUIConfig(config.UIConfig)
|
|
return true
|
|
}
|
|
|
|
func toResponseUIConfig(config *vipcardexperimentmod.UIConfig) *proto.VIPCardUIConfig {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
response := &proto.VIPCardUIConfig{
|
|
BackgroundImage: config.BackgroundImage,
|
|
BadgeStyles: make([]proto.VIPCardBadgeStyle, len(config.BadgeStyles)),
|
|
}
|
|
for i, style := range config.BadgeStyles {
|
|
response.BadgeStyles[i] = proto.VIPCardBadgeStyle{
|
|
BadgeType: style.BadgeType,
|
|
BackgroundColor: style.BackgroundColor,
|
|
TextColor: style.TextColor,
|
|
}
|
|
}
|
|
return response
|
|
}
|
|
|
|
func applyVariant(response *proto.ProductRes, config vipcardexperimentmod.VariantConfig) bool {
|
|
productByID := make(map[primitive.ObjectID]proto.VIPListRes)
|
|
positionByID := make(map[primitive.ObjectID]int)
|
|
for positionIndex := range response.List {
|
|
for _, product := range response.List[positionIndex].List {
|
|
productByID[product.ID] = product
|
|
positionByID[product.ID] = positionIndex
|
|
}
|
|
}
|
|
|
|
badges := make(map[primitive.ObjectID]vipcardexperimentmod.ProductBadge, len(config.ProductBadges))
|
|
for _, badge := range config.ProductBadges {
|
|
badges[badge.ProductID] = badge
|
|
}
|
|
affectedPositions := make(map[int]struct{})
|
|
orderedPositionIndexes := make([]int, 0, len(response.List))
|
|
selectedByPosition := make(map[int][]proto.VIPListRes)
|
|
defaultVisible := config.DefaultProductID.IsZero()
|
|
for productOrder, productID := range config.ProductIDs {
|
|
product, exists := productByID[productID]
|
|
if !exists {
|
|
continue
|
|
}
|
|
product.Sort = productOrder + 1
|
|
positionIndex := positionByID[productID]
|
|
if _, affected := affectedPositions[positionIndex]; !affected {
|
|
affectedPositions[positionIndex] = struct{}{}
|
|
orderedPositionIndexes = append(orderedPositionIndexes, positionIndex)
|
|
}
|
|
if badge, exists := badges[productID]; exists {
|
|
product.BadgeType = badge.BadgeType
|
|
product.BadgeText = badge.BadgeText
|
|
}
|
|
if productID == config.DefaultProductID {
|
|
defaultVisible = true
|
|
}
|
|
selectedByPosition[positionIndex] = append(selectedByPosition[positionIndex], product)
|
|
}
|
|
if len(affectedPositions) == 0 {
|
|
return false
|
|
}
|
|
if !defaultVisible {
|
|
return false
|
|
}
|
|
firstAffectedPosition := len(response.List)
|
|
for positionIndex := range affectedPositions {
|
|
if positionIndex < firstAffectedPosition {
|
|
firstAffectedPosition = positionIndex
|
|
}
|
|
}
|
|
orderedPositions := make([]proto.ProductList, 0, len(orderedPositionIndexes))
|
|
for _, positionIndex := range orderedPositionIndexes {
|
|
position := response.List[positionIndex]
|
|
position.List = selectedByPosition[positionIndex]
|
|
orderedPositions = append(orderedPositions, position)
|
|
}
|
|
positions := make([]proto.ProductList, 0, len(response.List))
|
|
for positionIndex, position := range response.List {
|
|
if positionIndex == firstAffectedPosition {
|
|
positions = append(positions, orderedPositions...)
|
|
}
|
|
if _, affected := affectedPositions[positionIndex]; affected {
|
|
continue
|
|
}
|
|
positions = append(positions, position)
|
|
}
|
|
response.List = positions
|
|
return true
|
|
}
|
|
|
|
// filterOtherVariantProducts removes products configured exclusively for the
|
|
// other experiment variant while preserving positions that do not participate
|
|
// in the experiment. This keeps the product response consistent with the
|
|
// attribution validation performed when an order is created.
|
|
func filterOtherVariantProducts(
|
|
response *proto.ProductRes,
|
|
currentProductIDs, otherProductIDs []primitive.ObjectID,
|
|
) {
|
|
if response == nil {
|
|
return
|
|
}
|
|
current := make(map[primitive.ObjectID]struct{}, len(currentProductIDs))
|
|
for _, productID := range currentProductIDs {
|
|
current[productID] = struct{}{}
|
|
}
|
|
blocked := make(map[primitive.ObjectID]struct{}, len(otherProductIDs))
|
|
for _, productID := range otherProductIDs {
|
|
if _, shared := current[productID]; !shared {
|
|
blocked[productID] = struct{}{}
|
|
}
|
|
}
|
|
if len(blocked) == 0 {
|
|
return
|
|
}
|
|
|
|
positions := make([]proto.ProductList, 0, len(response.List))
|
|
for _, position := range response.List {
|
|
products := make([]proto.VIPListRes, 0, len(position.List))
|
|
for _, product := range position.List {
|
|
if _, excluded := blocked[product.ID]; excluded {
|
|
continue
|
|
}
|
|
products = append(products, product)
|
|
}
|
|
if len(products) == 0 {
|
|
continue
|
|
}
|
|
position.List = products
|
|
positions = append(positions, position)
|
|
}
|
|
response.List = positions
|
|
}
|
|
|
|
func RecordEvents(uid uint64, request EventsRequest, now time.Time) (EventsResponse, error) {
|
|
response := EventsResponse{}
|
|
if uid == 0 {
|
|
return response, fmt.Errorf("authenticated user is required")
|
|
}
|
|
if len(request.Events) == 0 || len(request.Events) > maxEventsPerRequest {
|
|
return response, fmt.Errorf("events must contain 1-%d items", maxEventsPerRequest)
|
|
}
|
|
experiments := make(map[string]*vipcardexperimentmod.Experiment)
|
|
events := make([]*vipcardexperimentmod.AnalyticsEvent, 0, len(request.Events))
|
|
for _, input := range request.Events {
|
|
event, experiment, err := validateEvent(uid, input, now, experiments)
|
|
if err != nil {
|
|
return EventsResponse{}, err
|
|
}
|
|
events = append(events, event)
|
|
experiments[experiment.ExperimentID] = experiment
|
|
}
|
|
for _, event := range events {
|
|
inserted, err := vipcardexperimentmod.InsertEvent(event)
|
|
if err != nil {
|
|
return EventsResponse{}, err
|
|
}
|
|
if inserted {
|
|
response.Accepted++
|
|
} else {
|
|
response.Duplicated++
|
|
}
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func validateEvent(
|
|
uid uint64,
|
|
input EventInput,
|
|
now time.Time,
|
|
experiments map[string]*vipcardexperimentmod.Experiment,
|
|
) (*vipcardexperimentmod.AnalyticsEvent, *vipcardexperimentmod.Experiment, error) {
|
|
input.EventID = strings.TrimSpace(input.EventID)
|
|
input.EventName = strings.ToUpper(strings.TrimSpace(input.EventName))
|
|
input.SessionID = strings.TrimSpace(input.SessionID)
|
|
input.ExperimentID = strings.TrimSpace(input.ExperimentID)
|
|
input.Variant = strings.ToUpper(strings.TrimSpace(input.Variant))
|
|
if input.EventID == "" || len(input.EventID) > 128 {
|
|
return nil, nil, fmt.Errorf("eventId is required and must not exceed 128 characters")
|
|
}
|
|
if !vipcardexperimentmod.ValidEventName(input.EventName) {
|
|
return nil, nil, fmt.Errorf("unsupported eventName: %s", input.EventName)
|
|
}
|
|
if input.SessionID == "" || len(input.SessionID) > 128 {
|
|
return nil, nil, fmt.Errorf("sessionId is required and must not exceed 128 characters")
|
|
}
|
|
if input.ExperimentID == "" {
|
|
return nil, nil, fmt.Errorf("experimentId is required")
|
|
}
|
|
if len(input.ExperimentID) > 128 {
|
|
return nil, nil, fmt.Errorf("experimentId must not exceed 128 characters")
|
|
}
|
|
if input.OccurredAt.IsZero() || input.OccurredAt.After(now.Add(5*time.Minute)) {
|
|
return nil, nil, fmt.Errorf("occurredAt is invalid")
|
|
}
|
|
experiment := experiments[input.ExperimentID]
|
|
var err error
|
|
if experiment == nil {
|
|
experiment, err = vipcardexperimentmod.FindByExperimentID(input.ExperimentID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if experiment == nil {
|
|
return nil, nil, fmt.Errorf("experiment does not exist")
|
|
}
|
|
}
|
|
if !eventOccurredWithinExperiment(experiment, input.OccurredAt) {
|
|
return nil, nil, fmt.Errorf("occurredAt is outside the experiment period")
|
|
}
|
|
config, ok := experiment.ConfigFor(input.Variant)
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("variant must be A or B")
|
|
}
|
|
if assigned := experiment.Assign(uid); assigned != input.Variant {
|
|
return nil, nil, fmt.Errorf("variant does not match user assignment")
|
|
}
|
|
if input.EventName == vipcardexperimentmod.EventProductImpression {
|
|
if input.ProductID.IsZero() {
|
|
return nil, nil, fmt.Errorf("productId is required for VIP_PRODUCT_IMPRESSION")
|
|
}
|
|
if !containsProduct(config.ProductIDs, input.ProductID) {
|
|
return nil, nil, fmt.Errorf("productId does not belong to variant")
|
|
}
|
|
} else {
|
|
input.ProductID = primitive.NilObjectID
|
|
}
|
|
return &vipcardexperimentmod.AnalyticsEvent{
|
|
EventID: input.EventID,
|
|
EventName: input.EventName,
|
|
UID: uid,
|
|
SessionID: input.SessionID,
|
|
OccurredAt: input.OccurredAt,
|
|
ExperimentID: input.ExperimentID,
|
|
Variant: input.Variant,
|
|
ProductID: input.ProductID,
|
|
}, experiment, nil
|
|
}
|
|
|
|
func eventOccurredWithinExperiment(experiment *vipcardexperimentmod.Experiment, occurredAt time.Time) bool {
|
|
const clockSkew = 5 * time.Minute
|
|
startAt := experiment.PublishedAt
|
|
if experiment.StartAt != nil && (startAt.IsZero() || experiment.StartAt.After(startAt)) {
|
|
startAt = *experiment.StartAt
|
|
}
|
|
if !startAt.IsZero() && occurredAt.Before(startAt.Add(-clockSkew)) {
|
|
return false
|
|
}
|
|
var endAt *time.Time
|
|
if experiment.EndAt != nil {
|
|
end := *experiment.EndAt
|
|
endAt = &end
|
|
}
|
|
if experiment.DisabledAt != nil && (endAt == nil || experiment.DisabledAt.Before(*endAt)) {
|
|
end := *experiment.DisabledAt
|
|
endAt = &end
|
|
}
|
|
return endAt == nil || !occurredAt.After(endAt.Add(clockSkew))
|
|
}
|
|
|
|
func containsProduct(ids []primitive.ObjectID, target primitive.ObjectID) bool {
|
|
for _, id := range ids {
|
|
if id == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|