zhx dc8435f869 增加幸运礼物 AB 实验:按用户哈希分流灰度发布配置版本
- lucky_gift_experiments/overrides 表(006 迁移,含分组统计索引与 binary user_key)
- 创建实验=同事务发布 treatment+登记;两组强制同策略;实验期常规发布拒绝
- Check/单抽/批量/外部抽按用户钉住组内版本;结束实验重发布获胜快照全量收敛
- AdminLuckyGiftService 新增 5 个实验 RPC(放量字段 proto3 optional)
- admin 实验管理 HTTP API + lucky-gift:experiment 权限(迁移 103)
- 设计文档 docs/幸运礼物AB实验.md;域/真库/admin 回归测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:22:12 +08:00

157 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}