157 lines
5.9 KiB
Go
157 lines
5.9 KiB
Go
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 解析到同一组。
|
||
// 放量/白名单/暂停操作仍可能让两次解析落入不同组,因此两代策略都必须兼容旧客户端缺少的可选事实;
|
||
// dynamic_v3 的 paid_at_ms 始终来自扣费后的 wallet 回执,不能依赖 Check 阶段尚不存在的支付事实。
|
||
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
|
||
}
|