package luckygift import ( "hash/fnv" "strconv" "strings" ) const ( // 实验组常量。control 钉住实验开始时的最新已启用版本,treatment 钉住随实验原子发布的新版本。 ExperimentArmControl = "control" ExperimentArmTreatment = "treatment" // paused 时全部流量(含白名单 override)回落 control,作为不销毁分组事实的紧急停闸。 ExperimentStatusRunning = "running" ExperimentStatusPaused = "paused" ExperimentStatusCompleted = "completed" // ExperimentTrafficPPMScale 是 treatment 放量比例的满量程;与全仓 ppm 口径一致。 ExperimentTrafficPPMScale int64 = 1_000_000 // MaxExperimentOverrides 是单实验白名单的累计上限(单次请求与库内总量都受它约束)。 // 白名单是放量前的验证工具,不是花名册式定向投放。 MaxExperimentOverrides = 500 ) // Experiment 是单奖池 AB 实验事实。两组各自钉住一个不可变 rule_version, // 运行中"最新版本生效"的默认语义被实验钉住的版本替代;同一奖池最多一个未完成实验。 type Experiment struct { AppCode string `json:"app_code"` PoolID string `json:"pool_id"` ExperimentID string `json:"experiment_id"` Name string `json:"name"` ControlRuleVersion int64 `json:"control_rule_version"` TreatmentRuleVersion int64 `json:"treatment_rule_version"` // TreatmentTrafficPPM ∈ [0, 1000000];0 表示只有白名单 override 进入 treatment。 TreatmentTrafficPPM int64 `json:"treatment_traffic_ppm"` BucketSalt string `json:"bucket_salt"` Status string `json:"status"` WinnerArm string `json:"winner_arm"` WinnerRuleVersion int64 `json:"winner_rule_version"` // FinalRuleVersion 是结束实验时把获胜配置重发布成的新最新版本;全量流量由它承接。 FinalRuleVersion int64 `json:"final_rule_version"` CreatedByAdminID int64 `json:"created_by_admin_id"` StartedAtMS int64 `json:"started_at_ms"` EndedAtMS int64 `json:"ended_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` Overrides []ExperimentOverride `json:"overrides,omitempty"` ArmStats []ExperimentArmStats `json:"arm_stats,omitempty"` } // ExperimentOverride 把单个用户强制固定到某个实验组,多用于放量前的白名单验证。 type ExperimentOverride struct { UserKey string `json:"user_key"` Arm string `json:"arm"` } // ExperimentArmStats 是实验开始以来单组的抽奖聚合,按 rule_version 从抽奖事实现算。 type ExperimentArmStats struct { Arm string `json:"arm"` RuleVersion int64 `json:"rule_version"` StrategyVersion string `json:"strategy_version"` TotalDraws int64 `json:"total_draws"` UniqueUsers int64 `json:"unique_users"` TotalSpentCoins int64 `json:"total_spent_coins"` TotalRewardCoins int64 `json:"total_reward_coins"` ActualRTPPPM int64 `json:"actual_rtp_ppm"` } type CreateExperimentCommand struct { Name string TreatmentConfig RuleConfig TreatmentTrafficPPM int64 OperatorAdminID int64 Overrides []ExperimentOverride } type UpdateExperimentCommand struct { ExperimentID string // TreatmentTrafficPPM < 0 表示不修改流量。 TreatmentTrafficPPM int64 // Status 为空表示不修改状态;仅允许 running/paused 之间切换。 Status string OperatorAdminID int64 } type SetExperimentOverridesCommand struct { ExperimentID string Upserts []ExperimentOverride Removes []string OperatorAdminID int64 } type EndExperimentCommand struct { ExperimentID string WinnerArm string OperatorAdminID int64 } type ExperimentQuery struct { AppCode string PoolID string Status string WithStats bool } // ExperimentUserKey 归一实验分桶与白名单使用的用户标识: // 内部用户为十进制 user_id,外部用户为 "ext:" + external_user_id。 func ExperimentUserKey(userID int64) string { return strconv.FormatInt(userID, 10) } func ExperimentExternalUserKey(externalUserID string) string { return "ext:" + strings.TrimSpace(externalUserID) } // ExperimentBucketPPM 把用户确定性地映射到 [0, 1000000) 的分桶坐标。 // 对同一 salt+userKey 必须稳定:实验状态不变时 Check 与 Draw 解析到同一组。 // 放量/白名单/暂停操作仍会让分组在两次解析间漂移——这正是创建实验强制两组同策略的原因: // 同策略下漂移只是参数差异,不会击穿 room saga 固化 strategy_version 后对 device/paid_at 的前置拦截。 func ExperimentBucketPPM(salt string, userKey string) int64 { hasher := fnv.New64a() _, _ = hasher.Write([]byte(salt)) _, _ = hasher.Write([]byte{':'}) _, _ = hasher.Write([]byte(userKey)) return int64(hasher.Sum64() % uint64(ExperimentTrafficPPMScale)) } // ResolveExperimentArm 返回用户所属实验组。 // 优先级:paused 全量 control > 白名单 override > 哈希放量。 // 放量比较使用 bucket < traffic:调大流量时既有 treatment 用户全部保留,只有新用户迁入,保证单调放量。 func ResolveExperimentArm(experiment Experiment, userKey string, override string) string { if experiment.Status != ExperimentStatusRunning { return ExperimentArmControl } switch override { case ExperimentArmControl, ExperimentArmTreatment: return override } if ExperimentBucketPPM(experiment.BucketSalt, userKey) < experiment.TreatmentTrafficPPM { return ExperimentArmTreatment } return ExperimentArmControl } // ExperimentArmRuleVersion 返回某组钉住的配置版本。 func ExperimentArmRuleVersion(experiment Experiment, arm string) int64 { if arm == ExperimentArmTreatment { return experiment.TreatmentRuleVersion } return experiment.ControlRuleVersion } func ValidExperimentArm(arm string) bool { return arm == ExperimentArmControl || arm == ExperimentArmTreatment }