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
+283
View File
@@ -0,0 +1,283 @@
package vipcardexperimentmod
import (
"fmt"
"strings"
"time"
"91porn-server/common/db"
"91porn-server/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var mdb *db.MongoDB
func Init() {
mdb = db.Init(models.VIPCardExperiment)
initIndexes()
}
func experimentColl() *db.MongoTool {
return mdb.Coll(models.VIPCardExperiment)
}
func eventColl() *db.MongoTool {
return mdb.Coll(models.VIPCardAnalyticsEvent)
}
func initIndexes() {
if _, err := experimentColl().CreateIndex([]mongo.IndexModel{
{
Keys: bson.D{{Key: "experimentId", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "activeSlot", Value: 1}},
Options: options.Index().
SetName("uniq_vip_card_active_slot").
SetUnique(true).
SetPartialFilterExpression(bson.M{"activeSlot": bson.M{"$gt": ""}}),
},
{
Keys: bson.D{
{Key: "status", Value: 1},
{Key: "startAt", Value: 1},
{Key: "endAt", Value: 1},
{Key: "publishedAt", Value: -1},
},
},
}); err != nil {
panic(fmt.Sprintf("%s model set index err ==>[%+v]", models.VIPCardExperiment, err))
}
if _, err := eventColl().CreateIndex([]mongo.IndexModel{
{
Keys: bson.D{{Key: "eventId", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{
{Key: "experimentId", Value: 1},
{Key: "variant", Value: 1},
{Key: "eventName", Value: 1},
{Key: "occurredAt", Value: 1},
},
},
{
Keys: bson.D{
{Key: "experimentId", Value: 1},
{Key: "productId", Value: 1},
{Key: "eventName", Value: 1},
},
},
}); err != nil {
panic(fmt.Sprintf("%s model set index err ==>[%+v]", models.VIPCardAnalyticsEvent, err))
}
}
func Publish(experiment *Experiment, operator string) error {
experiment.Normalize()
if err := experiment.Validate(); err != nil {
return err
}
now := time.Now()
experiment.ID = primitive.NewObjectID()
experiment.Status = StatusActive
experiment.PublishedAt = now
experiment.CreatedAt = now
experiment.UpdatedAt = now
experiment.CreatedBy = operator
experiment.DisabledAt = nil
experiment.DisabledBy = ""
experiment.ActiveSlot = activeSlot
return mdb.Trans(func(t *db.MongoTool) error {
coll := t.Coll(models.VIPCardExperiment)
if _, err := coll.UpdateMany(
bson.M{"status": StatusActive},
bson.M{
"$set": bson.M{
"status": StatusDisabled,
"disabledAt": now,
"disabledBy": operator,
"updatedAt": now,
},
"$unset": bson.M{"activeSlot": ""},
},
); err != nil {
return err
}
_, err := coll.InsertOne(experiment)
return err
})
}
func Current(now time.Time) (*Experiment, error) {
filter := bson.M{
"status": StatusActive,
"$and": bson.A{
bson.M{"$or": bson.A{
bson.M{"startAt": bson.M{"$exists": false}},
bson.M{"startAt": nil},
bson.M{"startAt": bson.M{"$lte": now}},
}},
bson.M{"$or": bson.A{
bson.M{"endAt": bson.M{"$exists": false}},
bson.M{"endAt": nil},
bson.M{"endAt": bson.M{"$gt": now}},
}},
},
}
var out Experiment
if err := experimentColl().FindOne(
&out,
filter,
options.FindOne().SetSort(bson.D{{Key: "publishedAt", Value: -1}, {Key: "_id", Value: -1}}),
); err != nil {
return nil, err
}
if out.ID.IsZero() {
return nil, nil
}
return &out, nil
}
func FindByExperimentID(experimentID string) (*Experiment, error) {
var out Experiment
if err := experimentColl().FindOne(&out, bson.M{"experimentId": experimentID}); err != nil {
return nil, err
}
if out.ID.IsZero() {
return nil, nil
}
return &out, nil
}
func Disable(experimentID, operator string) (bool, error) {
now := time.Now()
result, err := experimentColl().UpdateMany(
bson.M{"experimentId": experimentID, "status": StatusActive},
bson.M{
"$set": bson.M{
"status": StatusDisabled,
"disabledAt": now,
"disabledBy": operator,
"updatedAt": now,
},
"$unset": bson.M{"activeSlot": ""},
},
)
if err != nil {
return false, err
}
return result.ModifiedCount > 0, nil
}
func InsertEvent(event *AnalyticsEvent) (bool, error) {
event.EventID = strings.TrimSpace(event.EventID)
event.EventName = strings.ToUpper(strings.TrimSpace(event.EventName))
event.SessionID = strings.TrimSpace(event.SessionID)
event.ExperimentID = strings.TrimSpace(event.ExperimentID)
event.Variant = strings.ToUpper(strings.TrimSpace(event.Variant))
event.ID = primitive.NewObjectID()
event.ReceivedAt = time.Now()
result, err := eventColl().UpsertOne(
bson.M{"eventId": event.EventID},
bson.M{"$setOnInsert": event},
)
if err != nil {
return false, err
}
return result.UpsertedCount > 0, nil
}
func EventStatistics(experimentID string) ([]EventStat, []ProductImpressionStat, error) {
var eventRaw []struct {
ID struct {
Variant string `bson:"variant"`
EventName string `bson:"eventName"`
} `bson:"_id"`
People int `bson:"people"`
Times int `bson:"times"`
}
err := eventColl().Aggregate(&eventRaw, []bson.M{
{"$match": bson.M{"experimentId": experimentID}},
{"$group": bson.M{
"_id": bson.M{
"variant": "$variant",
"eventName": "$eventName",
"uid": "$uid",
},
"userTimes": bson.M{"$sum": 1},
}},
{"$group": bson.M{
"_id": bson.M{
"variant": "$_id.variant",
"eventName": "$_id.eventName",
},
"people": bson.M{"$sum": 1},
"times": bson.M{"$sum": "$userTimes"},
}},
{"$sort": bson.D{{Key: "_id.variant", Value: 1}, {Key: "_id.eventName", Value: 1}}},
})
if err != nil {
return nil, nil, err
}
eventStats := make([]EventStat, 0, len(eventRaw))
for _, item := range eventRaw {
eventStats = append(eventStats, EventStat{
Variant: item.ID.Variant,
EventName: item.ID.EventName,
People: item.People,
Times: item.Times,
})
}
var productRaw []struct {
ID struct {
Variant string `bson:"variant"`
ProductID primitive.ObjectID `bson:"productId"`
} `bson:"_id"`
People int `bson:"people"`
Times int `bson:"times"`
}
err = eventColl().Aggregate(&productRaw, []bson.M{
{"$match": bson.M{
"experimentId": experimentID,
"eventName": EventProductImpression,
}},
{"$group": bson.M{
"_id": bson.M{
"variant": "$variant",
"productId": "$productId",
"uid": "$uid",
},
"userTimes": bson.M{"$sum": 1},
}},
{"$group": bson.M{
"_id": bson.M{
"variant": "$_id.variant",
"productId": "$_id.productId",
},
"people": bson.M{"$sum": 1},
"times": bson.M{"$sum": "$userTimes"},
}},
{"$sort": bson.D{{Key: "_id.variant", Value: 1}, {Key: "times", Value: -1}}},
})
if err != nil {
return nil, nil, err
}
productStats := make([]ProductImpressionStat, 0, len(productRaw))
for _, item := range productRaw {
productStats = append(productStats, ProductImpressionStat{
Variant: item.ID.Variant,
ProductID: item.ID.ProductID,
People: item.People,
Times: item.Times,
})
}
return eventStats, productStats, nil
}
@@ -0,0 +1,163 @@
package vipcardexperimentmod
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/bsontype"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// OrderedProductIDs exposes productIds as an order-number-to-product-ID map
// while keeping a slice internally so every backend consumer has deterministic
// numeric order. BSON is stored as an embedded document with keys 1..n.
// Legacy JSON/BSON arrays remain readable for existing experiment records.
type OrderedProductIDs []primitive.ObjectID
func (ids OrderedProductIDs) MarshalJSON() ([]byte, error) {
var buffer bytes.Buffer
buffer.WriteByte('{')
for index, productID := range ids {
if index > 0 {
buffer.WriteByte(',')
}
key, _ := json.Marshal(strconv.Itoa(index + 1))
value, err := json.Marshal(productID)
if err != nil {
return nil, err
}
buffer.Write(key)
buffer.WriteByte(':')
buffer.Write(value)
}
buffer.WriteByte('}')
return buffer.Bytes(), nil
}
func (ids *OrderedProductIDs) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return fmt.Errorf("productIds is required")
}
if bytes.Equal(data, []byte("null")) {
*ids = nil
return nil
}
switch data[0] {
case '[':
var legacy []primitive.ObjectID
if err := json.Unmarshal(data, &legacy); err != nil {
return fmt.Errorf("invalid legacy productIds: %w", err)
}
*ids = legacy
return nil
case '{':
var values map[string]json.RawMessage
if err := json.Unmarshal(data, &values); err != nil {
return fmt.Errorf("invalid productIds map: %w", err)
}
ordered := make(OrderedProductIDs, len(values))
occupied := make([]bool, len(values))
seenProducts := make(map[primitive.ObjectID]struct{}, len(values))
for key, rawProductID := range values {
index, err := parseProductOrder(key, len(values))
if err != nil {
return err
}
var productID primitive.ObjectID
if err = json.Unmarshal(rawProductID, &productID); err != nil || productID.IsZero() {
return fmt.Errorf("productIds.%s must be a valid product ID", key)
}
if occupied[index] {
return fmt.Errorf("productIds order %s is duplicated", key)
}
if _, exists := seenProducts[productID]; exists {
return fmt.Errorf("productIds contains duplicate product ID: %s", productID.Hex())
}
occupied[index] = true
seenProducts[productID] = struct{}{}
ordered[index] = productID
}
*ids = ordered
return nil
default:
return fmt.Errorf("productIds must be an order map")
}
}
func (ids OrderedProductIDs) MarshalBSONValue() (bsontype.Type, []byte, error) {
document := make(bson.D, 0, len(ids))
for index, productID := range ids {
document = append(document, bson.E{
Key: strconv.Itoa(index + 1),
Value: productID,
})
}
return bson.MarshalValue(document)
}
func (ids *OrderedProductIDs) UnmarshalBSONValue(valueType bsontype.Type, data []byte) error {
rawValue := bson.RawValue{Type: valueType, Value: data}
switch valueType {
case bsontype.Array:
var legacy []primitive.ObjectID
if err := rawValue.Unmarshal(&legacy); err != nil {
return fmt.Errorf("invalid legacy productIds: %w", err)
}
*ids = legacy
return nil
case bsontype.EmbeddedDocument:
var document bson.Raw
if err := rawValue.Unmarshal(&document); err != nil {
return fmt.Errorf("invalid productIds document: %w", err)
}
elements, err := document.Elements()
if err != nil {
return fmt.Errorf("invalid productIds document: %w", err)
}
ordered := make(OrderedProductIDs, len(elements))
occupied := make([]bool, len(elements))
seenProducts := make(map[primitive.ObjectID]struct{}, len(elements))
for _, element := range elements {
key := element.Key()
index, parseErr := parseProductOrder(key, len(elements))
if parseErr != nil {
return parseErr
}
productID, ok := element.Value().ObjectIDOK()
if !ok || productID.IsZero() {
return fmt.Errorf("productIds.%s must be a valid product ID", key)
}
if occupied[index] {
return fmt.Errorf("productIds order %s is duplicated", key)
}
if _, exists := seenProducts[productID]; exists {
return fmt.Errorf("productIds contains duplicate product ID: %s", productID.Hex())
}
occupied[index] = true
seenProducts[productID] = struct{}{}
ordered[index] = productID
}
*ids = ordered
return nil
case bsontype.Null, bsontype.Undefined:
*ids = nil
return nil
default:
return fmt.Errorf("productIds must be an ordered document")
}
}
func parseProductOrder(key string, size int) (int, error) {
order, err := strconv.Atoi(key)
if err != nil || order <= 0 || strconv.Itoa(order) != key {
return 0, fmt.Errorf("productIds order must be a positive integer: %s", key)
}
if order > size {
return 0, fmt.Errorf("productIds order must be continuous from 1")
}
return order - 1, nil
}
+294
View File
@@ -0,0 +1,294 @@
package vipcardexperimentmod
import (
"fmt"
"hash/fnv"
"regexp"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
StatusActive = "ACTIVE"
StatusDisabled = "DISABLED"
activeSlot = "VIP_CARD_CURRENT"
VariantA = "A"
VariantB = "B"
BadgeMostPopular = "MOST_POPULAR"
BadgeNewUser = "NEW_USER_OFFER"
EventCardPageView = "VIP_CARD_PAGE_VIEW"
EventProductImpression = "VIP_PRODUCT_IMPRESSION"
EventCloseWithoutPay = "VIP_CARD_CLOSE_WITHOUT_PURCHASE"
)
var validBadgeTypes = map[string]struct{}{
BadgeMostPopular: {},
BadgeNewUser: {},
}
var validEventNames = map[string]struct{}{
EventCardPageView: {},
EventProductImpression: {},
EventCloseWithoutPay: {},
}
var hexColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$`)
type ProductBadge struct {
ProductID primitive.ObjectID `json:"productId" bson:"productId"`
BadgeType string `json:"badgeType" bson:"badgeType"`
BadgeText string `json:"badgeText" bson:"badgeText"`
}
type BadgeStyle struct {
BadgeType string `json:"badgeType" bson:"badgeType"`
BackgroundColor string `json:"backgroundColor" bson:"backgroundColor"`
TextColor string `json:"textColor" bson:"textColor"`
}
type UIConfig struct {
BackgroundImage string `json:"backgroundImage" bson:"backgroundImage"`
BadgeStyles []BadgeStyle `json:"badgeStyles" bson:"badgeStyles"`
}
type VariantConfig struct {
Name string `json:"name" bson:"name"`
SkinKey string `json:"skinKey" bson:"skinKey"`
DefaultProductID primitive.ObjectID `json:"defaultProductId,omitempty" bson:"defaultProductId,omitempty"`
ProductIDs OrderedProductIDs `json:"productIds" bson:"productIds"`
UIConfig *UIConfig `json:"uiConfig,omitempty" bson:"uiConfig,omitempty"`
ProductBadges []ProductBadge `json:"productBadges" bson:"productBadges"`
}
type Experiment struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
ExperimentID string `json:"experimentId" bson:"experimentId"`
Name string `json:"name" bson:"name"`
Status string `json:"status" bson:"status"`
TrafficA int `json:"trafficA" bson:"trafficA"`
TrafficB int `json:"trafficB" bson:"trafficB"`
VariantA VariantConfig `json:"variantA" bson:"variantA"`
VariantB VariantConfig `json:"variantB" bson:"variantB"`
StartAt *time.Time `json:"startAt,omitempty" bson:"startAt,omitempty"`
EndAt *time.Time `json:"endAt,omitempty" bson:"endAt,omitempty"`
PublishedAt time.Time `json:"publishedAt" bson:"publishedAt"`
DisabledAt *time.Time `json:"disabledAt,omitempty" bson:"disabledAt,omitempty"`
CreatedBy string `json:"createdBy" bson:"createdBy"`
DisabledBy string `json:"disabledBy,omitempty" bson:"disabledBy,omitempty"`
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"`
ActiveSlot string `json:"-" bson:"activeSlot,omitempty"`
}
type AnalyticsEvent struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
EventID string `json:"eventId" bson:"eventId"`
EventName string `json:"eventName" bson:"eventName"`
UID uint64 `json:"uid" bson:"uid"`
SessionID string `json:"sessionId" bson:"sessionId"`
OccurredAt time.Time `json:"occurredAt" bson:"occurredAt"`
ExperimentID string `json:"experimentId" bson:"experimentId"`
Variant string `json:"variant" bson:"variant"`
ProductID primitive.ObjectID `json:"productId,omitempty" bson:"productId,omitempty"`
ReceivedAt time.Time `json:"receivedAt" bson:"receivedAt"`
}
type EventStat struct {
Variant string `json:"variant" bson:"variant"`
EventName string `json:"eventName" bson:"eventName"`
People int `json:"people" bson:"people"`
Times int `json:"times" bson:"times"`
}
type ProductImpressionStat struct {
Variant string `json:"variant" bson:"variant"`
ProductID primitive.ObjectID `json:"productId" bson:"productId"`
People int `json:"people" bson:"people"`
Times int `json:"times" bson:"times"`
}
func (c *UIConfig) Normalize() {
if c == nil {
return
}
c.BackgroundImage = strings.TrimSpace(c.BackgroundImage)
if c.BadgeStyles == nil {
c.BadgeStyles = make([]BadgeStyle, 0)
return
}
for i := range c.BadgeStyles {
c.BadgeStyles[i].BadgeType = strings.ToUpper(strings.TrimSpace(c.BadgeStyles[i].BadgeType))
c.BadgeStyles[i].BackgroundColor = strings.ToUpper(strings.TrimSpace(c.BadgeStyles[i].BackgroundColor))
c.BadgeStyles[i].TextColor = strings.ToUpper(strings.TrimSpace(c.BadgeStyles[i].TextColor))
}
}
func (c UIConfig) Validate(name string) error {
if len(c.BackgroundImage) > 2048 {
return fmt.Errorf("%s.backgroundImage is too long", name)
}
seenTypes := make(map[string]struct{}, len(c.BadgeStyles))
for _, style := range c.BadgeStyles {
if _, ok := validBadgeTypes[style.BadgeType]; !ok {
return fmt.Errorf("%s unsupported badgeType: %s", name, style.BadgeType)
}
if _, exists := seenTypes[style.BadgeType]; exists {
return fmt.Errorf("%s allows only one style per badgeType", name)
}
seenTypes[style.BadgeType] = struct{}{}
if !hexColorPattern.MatchString(style.BackgroundColor) {
return fmt.Errorf("%s invalid backgroundColor for %s", name, style.BadgeType)
}
if !hexColorPattern.MatchString(style.TextColor) {
return fmt.Errorf("%s invalid textColor for %s", name, style.BadgeType)
}
}
return nil
}
func (v *VariantConfig) Normalize() {
v.Name = strings.TrimSpace(v.Name)
v.SkinKey = strings.TrimSpace(v.SkinKey)
if v.UIConfig != nil {
v.UIConfig.Normalize()
if v.UIConfig.BackgroundImage == "" && len(v.UIConfig.BadgeStyles) == 0 {
v.UIConfig = nil
}
}
seenProducts := make(map[primitive.ObjectID]struct{}, len(v.ProductIDs))
productIDs := make(OrderedProductIDs, 0, len(v.ProductIDs))
for _, id := range v.ProductIDs {
if id.IsZero() {
continue
}
if _, exists := seenProducts[id]; exists {
continue
}
seenProducts[id] = struct{}{}
productIDs = append(productIDs, id)
}
v.ProductIDs = productIDs
badges := make([]ProductBadge, 0, len(v.ProductBadges))
for _, badge := range v.ProductBadges {
badge.BadgeType = strings.ToUpper(strings.TrimSpace(badge.BadgeType))
badge.BadgeText = strings.TrimSpace(badge.BadgeText)
if badge.BadgeText == "" {
switch badge.BadgeType {
case BadgeMostPopular:
badge.BadgeText = "最受欢迎"
case BadgeNewUser:
badge.BadgeText = "新人特惠"
}
}
badges = append(badges, badge)
}
v.ProductBadges = badges
}
func (v VariantConfig) Validate(name string) error {
if len(v.ProductIDs) == 0 {
return fmt.Errorf("%s.productIds is required", name)
}
productSet := make(map[primitive.ObjectID]struct{}, len(v.ProductIDs))
for _, id := range v.ProductIDs {
productSet[id] = struct{}{}
}
if !v.DefaultProductID.IsZero() {
if _, ok := productSet[v.DefaultProductID]; !ok {
return fmt.Errorf("%s.defaultProductId must belong to productIds", name)
}
}
if v.UIConfig != nil {
if err := v.UIConfig.Validate(name + ".uiConfig"); err != nil {
return err
}
}
badgeProducts := make(map[primitive.ObjectID]struct{}, len(v.ProductBadges))
for _, badge := range v.ProductBadges {
if badge.ProductID.IsZero() {
return fmt.Errorf("%s badge productId is required", name)
}
if _, exists := badgeProducts[badge.ProductID]; exists {
return fmt.Errorf("%s allows only one badge per product", name)
}
badgeProducts[badge.ProductID] = struct{}{}
if _, ok := productSet[badge.ProductID]; !ok {
return fmt.Errorf("%s badge productId must belong to productIds", name)
}
if _, ok := validBadgeTypes[badge.BadgeType]; !ok {
return fmt.Errorf("%s unsupported badgeType: %s", name, badge.BadgeType)
}
}
return nil
}
func (e *Experiment) Normalize() {
e.ExperimentID = strings.TrimSpace(e.ExperimentID)
e.Name = strings.TrimSpace(e.Name)
e.Status = strings.ToUpper(strings.TrimSpace(e.Status))
e.VariantA.Normalize()
e.VariantB.Normalize()
}
func (e Experiment) Validate() error {
if e.ExperimentID == "" {
return fmt.Errorf("experimentId is required")
}
if len(e.ExperimentID) > 128 {
return fmt.Errorf("experimentId is too long")
}
if e.TrafficA < 0 || e.TrafficB < 0 || e.TrafficA+e.TrafficB != 100 {
return fmt.Errorf("trafficA and trafficB must add up to 100")
}
if e.StartAt != nil && e.EndAt != nil && !e.EndAt.After(*e.StartAt) {
return fmt.Errorf("endAt must be later than startAt")
}
if err := e.VariantA.Validate("variantA"); err != nil {
return err
}
if err := e.VariantB.Validate("variantB"); err != nil {
return err
}
return nil
}
func (e Experiment) ActiveAt(now time.Time) bool {
if e.Status != StatusActive {
return false
}
if e.StartAt != nil && e.StartAt.After(now) {
return false
}
return e.EndAt == nil || e.EndAt.After(now)
}
func (e Experiment) Assign(uid uint64) string {
hasher := fnv.New32a()
_, _ = hasher.Write([]byte(fmt.Sprintf("%s:%d", e.ExperimentID, uid)))
if int(hasher.Sum32()%100) < e.TrafficA {
return VariantA
}
return VariantB
}
func (e Experiment) ConfigFor(variant string) (VariantConfig, bool) {
switch strings.ToUpper(strings.TrimSpace(variant)) {
case VariantA:
return e.VariantA, true
case VariantB:
return e.VariantB, true
default:
return VariantConfig{}, false
}
}
func ValidEventName(name string) bool {
_, ok := validEventNames[strings.ToUpper(strings.TrimSpace(name))]
return ok
}
+429
View File
@@ -0,0 +1,429 @@
package vipcardexperimentmod
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestExperimentNormalizeValidateAndAssign(t *testing.T) {
first := primitive.NewObjectID()
second := primitive.NewObjectID()
experiment := Experiment{
ExperimentID: " vip-card-test ",
TrafficA: 50,
TrafficB: 50,
VariantA: VariantConfig{
DefaultProductID: first,
ProductIDs: []primitive.ObjectID{first, first, second},
UIConfig: &UIConfig{
BackgroundImage: " card-skin-a.png ",
BadgeStyles: []BadgeStyle{{
BadgeType: " most_popular ",
BackgroundColor: " #f04432 ",
TextColor: " #ffffff ",
}},
},
ProductBadges: []ProductBadge{{
ProductID: first,
BadgeType: " most_popular ",
}},
},
VariantB: VariantConfig{
DefaultProductID: second,
ProductIDs: []primitive.ObjectID{second, first},
},
}
experiment.Normalize()
if err := experiment.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
if experiment.ExperimentID != "vip-card-test" {
t.Fatalf("ExperimentID = %q", experiment.ExperimentID)
}
if len(experiment.VariantA.ProductIDs) != 2 {
t.Fatalf("duplicate product IDs were not removed: %#v", experiment.VariantA.ProductIDs)
}
if got := experiment.VariantA.ProductBadges[0].BadgeText; got != "最受欢迎" {
t.Fatalf("default badge text = %q", got)
}
if got := experiment.VariantA.UIConfig.BackgroundImage; got != "card-skin-a.png" {
t.Fatalf("background image = %q", got)
}
style := experiment.VariantA.UIConfig.BadgeStyles[0]
if style.BadgeType != BadgeMostPopular ||
style.BackgroundColor != "#F04432" ||
style.TextColor != "#FFFFFF" {
t.Fatalf("normalized badge style = %#v", style)
}
firstAssignment := experiment.Assign(12345)
for i := 0; i < 10; i++ {
if got := experiment.Assign(12345); got != firstAssignment {
t.Fatalf("assignment changed: first=%s got=%s", firstAssignment, got)
}
}
}
func TestExperimentValidateRejectsInvalidConfigurations(t *testing.T) {
productID := primitive.NewObjectID()
base := Experiment{
ExperimentID: "experiment",
TrafficA: 50,
TrafficB: 50,
VariantA: VariantConfig{
DefaultProductID: productID,
ProductIDs: []primitive.ObjectID{productID},
},
VariantB: VariantConfig{
DefaultProductID: productID,
ProductIDs: []primitive.ObjectID{productID},
},
}
invalidTraffic := base
invalidTraffic.TrafficA = 90
if err := invalidTraffic.Validate(); err == nil {
t.Fatal("expected invalid traffic to fail")
}
invalidDefault := base
invalidDefault.VariantA.DefaultProductID = primitive.NewObjectID()
if err := invalidDefault.Validate(); err == nil {
t.Fatal("expected a default product outside productIds to fail")
}
duplicateMostPopular := base
duplicateMostPopular.VariantA.ProductIDs = append(
duplicateMostPopular.VariantA.ProductIDs,
primitive.NewObjectID(),
)
duplicateMostPopular.VariantA.ProductBadges = []ProductBadge{
{ProductID: duplicateMostPopular.VariantA.ProductIDs[0], BadgeType: BadgeMostPopular},
{ProductID: duplicateMostPopular.VariantA.ProductIDs[1], BadgeType: BadgeMostPopular},
}
if err := duplicateMostPopular.Validate(); err == nil {
t.Fatal("expected multiple MOST_POPULAR badges to fail")
}
}
func TestExperimentValidateAllowsMissingDefaultProduct(t *testing.T) {
productID := primitive.NewObjectID()
experiment := Experiment{
ExperimentID: "optional-default-product",
TrafficA: 50,
TrafficB: 50,
VariantA: VariantConfig{
ProductIDs: []primitive.ObjectID{productID},
},
VariantB: VariantConfig{
ProductIDs: []primitive.ObjectID{productID},
},
}
if err := experiment.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestUIConfigValidate(t *testing.T) {
valid := UIConfig{
BackgroundImage: "vip-card-skin.png",
BadgeStyles: []BadgeStyle{
{
BadgeType: BadgeNewUser,
BackgroundColor: "#F04432",
TextColor: "#FFFFFFFF",
},
{
BadgeType: BadgeMostPopular,
BackgroundColor: "#2B251A",
TextColor: "#F7D98C",
},
},
}
if err := valid.Validate("uiConfig"); err != nil {
t.Fatalf("valid UI config failed: %v", err)
}
tests := []struct {
name string
styles []BadgeStyle
want string
}{
{
name: "unsupported badge type",
styles: []BadgeStyle{{
BadgeType: "UNKNOWN",
BackgroundColor: "#F04432",
TextColor: "#FFFFFF",
}},
want: "unsupported badgeType",
},
{
name: "invalid background color",
styles: []BadgeStyle{{
BadgeType: BadgeNewUser,
BackgroundColor: "#FFF",
TextColor: "#FFFFFF",
}},
want: "invalid backgroundColor",
},
{
name: "invalid text color",
styles: []BadgeStyle{{
BadgeType: BadgeNewUser,
BackgroundColor: "#F04432",
TextColor: "white",
}},
want: "invalid textColor",
},
{
name: "duplicate badge type",
styles: []BadgeStyle{
{
BadgeType: BadgeNewUser,
BackgroundColor: "#F04432",
TextColor: "#FFFFFF",
},
{
BadgeType: BadgeNewUser,
BackgroundColor: "#2B251A",
TextColor: "#F7D98C",
},
},
want: "only one style per badgeType",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := UIConfig{BadgeStyles: tt.styles}
err := config.Validate("uiConfig")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want %q", err, tt.want)
}
})
}
}
func TestUIConfigNormalizeDropsEmptyConfig(t *testing.T) {
productID := primitive.NewObjectID()
variant := VariantConfig{
DefaultProductID: productID,
ProductIDs: []primitive.ObjectID{productID},
UIConfig: &UIConfig{},
}
variant.Normalize()
if variant.UIConfig != nil {
t.Fatalf("empty UI config = %#v, want nil", variant.UIConfig)
}
}
func TestExperimentValidateChecksVariantBUIConfig(t *testing.T) {
productID := primitive.NewObjectID()
experiment := Experiment{
ExperimentID: "variant-b-ui-check",
TrafficA: 50,
TrafficB: 50,
VariantA: VariantConfig{
DefaultProductID: productID,
ProductIDs: []primitive.ObjectID{productID},
},
VariantB: VariantConfig{
DefaultProductID: productID,
ProductIDs: []primitive.ObjectID{productID},
UIConfig: &UIConfig{BadgeStyles: []BadgeStyle{{
BadgeType: BadgeNewUser,
BackgroundColor: "#FFF",
TextColor: "#FFFFFF",
}}},
},
}
err := experiment.Validate()
if err == nil || !strings.Contains(err.Error(), "variantB.uiConfig") {
t.Fatalf("Validate() error = %v, want variantB.uiConfig error", err)
}
}
func TestUIConfigValidateBackgroundImageLength(t *testing.T) {
valid := UIConfig{BackgroundImage: strings.Repeat("a", 2048)}
if err := valid.Validate("uiConfig"); err != nil {
t.Fatalf("2048-character background image failed: %v", err)
}
invalid := UIConfig{BackgroundImage: strings.Repeat("a", 2049)}
if err := invalid.Validate("uiConfig"); err == nil {
t.Fatal("expected overlong background image to fail")
}
}
func TestExperimentUIConfigJSONAndBSONRoundTrip(t *testing.T) {
productID := primitive.NewObjectID()
experiment := Experiment{
ExperimentID: "vip-card-contract",
TrafficA: 100,
TrafficB: 0,
VariantA: VariantConfig{
DefaultProductID: productID,
ProductIDs: []primitive.ObjectID{productID},
UIConfig: &UIConfig{
BackgroundImage: "vip-card-skin-a.png",
BadgeStyles: []BadgeStyle{{
BadgeType: BadgeMostPopular,
BackgroundColor: "#2B251A",
TextColor: "#F7D98C",
}},
},
},
VariantB: VariantConfig{
DefaultProductID: productID,
ProductIDs: []primitive.ObjectID{productID},
},
}
jsonData, err := json.Marshal(experiment)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if !bytes.Contains(jsonData, []byte(`"uiConfig"`)) ||
!bytes.Contains(jsonData, []byte(`"backgroundColor":"#2B251A"`)) {
t.Fatalf("JSON contract missing UI config: %s", jsonData)
}
var fromJSON Experiment
if err = json.Unmarshal(jsonData, &fromJSON); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if fromJSON.VariantA.UIConfig == nil ||
fromJSON.VariantA.UIConfig.BackgroundImage != "vip-card-skin-a.png" ||
fromJSON.VariantB.UIConfig != nil {
t.Fatalf("JSON round trip = %#v", fromJSON)
}
bsonData, err := bson.Marshal(experiment)
if err != nil {
t.Fatalf("bson.Marshal() error = %v", err)
}
var fromBSON Experiment
if err = bson.Unmarshal(bsonData, &fromBSON); err != nil {
t.Fatalf("bson.Unmarshal() error = %v", err)
}
if fromBSON.VariantA.UIConfig == nil ||
fromBSON.VariantA.UIConfig.BadgeStyles[0].TextColor != "#F7D98C" ||
fromBSON.VariantB.UIConfig != nil {
t.Fatalf("BSON round trip = %#v", fromBSON)
}
}
func TestOrderedProductIDsJSONMapAndLegacyArray(t *testing.T) {
first := primitive.NewObjectID()
second := primitive.NewObjectID()
ids := OrderedProductIDs{first, second}
encoded, err := json.Marshal(ids)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
want := `{"1":"` + first.Hex() + `","2":"` + second.Hex() + `"}`
if string(encoded) != want {
t.Fatalf("JSON = %s, want %s", encoded, want)
}
var fromMap OrderedProductIDs
input := []byte(`{"2":"` + second.Hex() + `","1":"` + first.Hex() + `"}`)
if err = json.Unmarshal(input, &fromMap); err != nil {
t.Fatalf("map json.Unmarshal() error = %v", err)
}
if len(fromMap) != 2 || fromMap[0] != first || fromMap[1] != second {
t.Fatalf("map JSON order = %#v", fromMap)
}
var fromLegacy OrderedProductIDs
legacy := []byte(`["` + second.Hex() + `","` + first.Hex() + `"]`)
if err = json.Unmarshal(legacy, &fromLegacy); err != nil {
t.Fatalf("legacy json.Unmarshal() error = %v", err)
}
if len(fromLegacy) != 2 || fromLegacy[0] != second || fromLegacy[1] != first {
t.Fatalf("legacy JSON order = %#v", fromLegacy)
}
}
func TestOrderedProductIDsRejectsInvalidMap(t *testing.T) {
productID := primitive.NewObjectID().Hex()
tests := []string{
`{"2":"` + productID + `"}`,
`{"01":"` + productID + `"}`,
`{"1":"` + productID + `","2":"` + productID + `"}`,
}
for _, input := range tests {
var ids OrderedProductIDs
if err := json.Unmarshal([]byte(input), &ids); err == nil {
t.Fatalf("json.Unmarshal(%s) succeeded, want error", input)
}
}
}
func TestOrderedProductIDsBSONDocumentAndLegacyArray(t *testing.T) {
first := primitive.NewObjectID()
second := primitive.NewObjectID()
type orderedDocument struct {
ProductIDs OrderedProductIDs `bson:"productIds"`
}
encoded, err := bson.Marshal(orderedDocument{ProductIDs: OrderedProductIDs{first, second}})
if err != nil {
t.Fatalf("bson.Marshal() error = %v", err)
}
rawValue := bson.Raw(encoded).Lookup("productIds")
if rawValue.Type != bson.TypeEmbeddedDocument {
t.Fatalf("productIds BSON type = %s, want document", rawValue.Type)
}
elements, err := rawValue.Document().Elements()
if err != nil {
t.Fatalf("productIds document error = %v", err)
}
if len(elements) != 2 || elements[0].Key() != "1" || elements[1].Key() != "2" ||
elements[0].Value().ObjectID() != first || elements[1].Value().ObjectID() != second {
t.Fatalf("productIds BSON document = %#v", elements)
}
var fromDocument orderedDocument
if err = bson.Unmarshal(encoded, &fromDocument); err != nil {
t.Fatalf("document bson.Unmarshal() error = %v", err)
}
if len(fromDocument.ProductIDs) != 2 ||
fromDocument.ProductIDs[0] != first || fromDocument.ProductIDs[1] != second {
t.Fatalf("document BSON order = %#v", fromDocument.ProductIDs)
}
legacy, err := bson.Marshal(struct {
ProductIDs []primitive.ObjectID `bson:"productIds"`
}{ProductIDs: []primitive.ObjectID{second, first}})
if err != nil {
t.Fatalf("legacy bson.Marshal() error = %v", err)
}
var fromLegacy orderedDocument
if err = bson.Unmarshal(legacy, &fromLegacy); err != nil {
t.Fatalf("legacy bson.Unmarshal() error = %v", err)
}
if len(fromLegacy.ProductIDs) != 2 ||
fromLegacy.ProductIDs[0] != second || fromLegacy.ProductIDs[1] != first {
t.Fatalf("legacy BSON order = %#v", fromLegacy.ProductIDs)
}
}
func TestExperimentActiveAt(t *testing.T) {
now := time.Now()
start := now.Add(-time.Minute)
end := now.Add(time.Minute)
experiment := Experiment{Status: StatusActive, StartAt: &start, EndAt: &end}
if !experiment.ActiveAt(now) {
t.Fatal("expected active experiment")
}
past := now.Add(-2 * time.Minute)
experiment.EndAt = &past
if experiment.ActiveAt(now) {
t.Fatal("expired experiment must not be active")
}
}