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

313 lines
12 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 (
"fmt"
"strings"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
"github.com/gin-gonic/gin"
)
type experimentOverrideDTO struct {
UserKey string `json:"user_key"`
Arm string `json:"arm"`
}
type experimentArmStatsDTO 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"`
ActualRTPPercent float64 `json:"actual_rtp_percent"`
}
type experimentDTO 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"`
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
Status string `json:"status"`
WinnerArm string `json:"winner_arm"`
WinnerRuleVersion int64 `json:"winner_rule_version"`
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 []experimentOverrideDTO `json:"overrides"`
ArmStats []experimentArmStatsDTO `json:"arm_stats"`
}
type createExperimentRequest struct {
Name string `json:"name"`
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
Overrides []experimentOverrideDTO `json:"overrides"`
Config configRequest `json:"config"`
}
type updateExperimentRequest struct {
// 指针区分「没传」与「显式传 0」缺省时向 owner 传 -1 表示保持现有放量。
TreatmentTrafficPercent *float64 `json:"treatment_traffic_percent"`
Status string `json:"status"`
}
type setExperimentOverridesRequest struct {
Upserts []experimentOverrideDTO `json:"upserts"`
Removes []string `json:"removes"`
}
type endExperimentRequest struct {
WinnerArm string `json:"winner_arm"`
}
func (h *Handler) ListLuckyGiftExperiments(c *gin.Context) {
if strings.TrimSpace(c.Query("app_code")) == "" {
response.BadRequest(c, "app_code is required")
return
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.ListLuckyGiftExperiments(ctx, &luckygiftv1.ListLuckyGiftExperimentsRequest{
Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")),
Status: strings.ToLower(strings.TrimSpace(c.Query("status"))),
WithStats: parseOptionalBool(c.Query("with_stats")),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
items := make([]experimentDTO, 0, len(resp.GetExperiments()))
for _, experiment := range resp.GetExperiments() {
items = append(items, experimentFromProto(experiment))
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) CreateLuckyGiftExperiment(c *gin.Context) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
if appCode == "" {
response.BadRequest(c, "app_code is required")
return
}
var req createExperimentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "幸运礼物实验参数不正确")
return
}
if req.Config.AppCode == "" {
req.Config.AppCode = appCode
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.CreateLuckyGiftExperiment(ctx, &luckygiftv1.CreateLuckyGiftExperimentRequest{
Meta: h.metaForApp(c, appCode),
Name: strings.TrimSpace(req.Name),
// treatment 配置复用 PUT /lucky-gifts/config 的解析归一逻辑tier_id 生成、percent→ppm、
// dynamic_v3 initial_pool_coins 清零),保证实验发布的新版本与常规发布口径一致。
TreatmentConfig: configToProto(req.Config),
TreatmentTrafficPpm: percentToPPM(req.TreatmentTrafficPercent),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
Overrides: experimentOverridesToProto(req.Overrides),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
experiment := experimentFromProto(resp.GetExperiment())
shared.OperationLogWithResourceID(
c, h.audit, "create-lucky-gift-experiment", "lucky_gift_experiments", experiment.ExperimentID, "success",
fmt.Sprintf("app_code=%s pool_id=%s treatment_traffic_ppm=%d", appCode, experiment.PoolID, percentToPPM(req.TreatmentTrafficPercent)),
)
response.OK(c, gin.H{
"experiment": experiment,
"treatment_config": configFromProto(resp.GetTreatmentConfig()),
})
}
func (h *Handler) UpdateLuckyGiftExperiment(c *gin.Context) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
if appCode == "" {
response.BadRequest(c, "app_code is required")
return
}
experimentID := strings.TrimSpace(c.Param("experimentId"))
if experimentID == "" {
response.BadRequest(c, "experiment_id is required")
return
}
var req updateExperimentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "幸运礼物实验参数不正确")
return
}
trafficPPM := int64(-1)
grpcReq := &luckygiftv1.UpdateLuckyGiftExperimentRequest{
Meta: h.metaForApp(c, appCode),
ExperimentId: experimentID,
Status: strings.ToLower(strings.TrimSpace(req.Status)),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
}
if req.TreatmentTrafficPercent != nil {
trafficPPM = percentToPPM(*req.TreatmentTrafficPercent)
grpcReq.TreatmentTrafficPpm = &trafficPPM
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.UpdateLuckyGiftExperiment(ctx, grpcReq)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(
c, h.audit, "update-lucky-gift-experiment", "lucky_gift_experiments", experimentID, "success",
fmt.Sprintf("app_code=%s treatment_traffic_ppm=%d status=%s", appCode, trafficPPM, strings.ToLower(strings.TrimSpace(req.Status))),
)
response.OK(c, gin.H{"experiment": experimentFromProto(resp.GetExperiment())})
}
func (h *Handler) SetLuckyGiftExperimentOverrides(c *gin.Context) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
if appCode == "" {
response.BadRequest(c, "app_code is required")
return
}
experimentID := strings.TrimSpace(c.Param("experimentId"))
if experimentID == "" {
response.BadRequest(c, "experiment_id is required")
return
}
var req setExperimentOverridesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "幸运礼物实验白名单参数不正确")
return
}
removes := make([]string, 0, len(req.Removes))
for _, userKey := range req.Removes {
removes = append(removes, strings.TrimSpace(userKey))
}
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.SetLuckyGiftExperimentOverrides(ctx, &luckygiftv1.SetLuckyGiftExperimentOverridesRequest{
Meta: h.metaForApp(c, appCode),
ExperimentId: experimentID,
Upserts: experimentOverridesToProto(req.Upserts),
Removes: removes,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(
c, h.audit, "set-lucky-gift-experiment-overrides", "lucky_gift_experiments", experimentID, "success",
fmt.Sprintf("app_code=%s upserts=%d removes=%d", appCode, len(req.Upserts), len(removes)),
)
response.OK(c, gin.H{"experiment": experimentFromProto(resp.GetExperiment())})
}
func (h *Handler) EndLuckyGiftExperiment(c *gin.Context) {
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
if appCode == "" {
response.BadRequest(c, "app_code is required")
return
}
experimentID := strings.TrimSpace(c.Param("experimentId"))
if experimentID == "" {
response.BadRequest(c, "experiment_id is required")
return
}
var req endExperimentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "幸运礼物实验参数不正确")
return
}
winnerArm := strings.ToLower(strings.TrimSpace(req.WinnerArm))
ctx, cancel := h.luckyGiftContext(c)
defer cancel()
resp, err := h.luckyGift.EndLuckyGiftExperiment(ctx, &luckygiftv1.EndLuckyGiftExperimentRequest{
Meta: h.metaForApp(c, appCode),
ExperimentId: experimentID,
WinnerArm: winnerArm,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(
c, h.audit, "end-lucky-gift-experiment", "lucky_gift_experiments", experimentID, "success",
fmt.Sprintf("app_code=%s winner_arm=%s", appCode, winnerArm),
)
response.OK(c, gin.H{
"experiment": experimentFromProto(resp.GetExperiment()),
"final_config": configFromProto(resp.GetFinalConfig()),
})
}
func experimentOverridesToProto(overrides []experimentOverrideDTO) []*luckygiftv1.LuckyGiftExperimentOverride {
out := make([]*luckygiftv1.LuckyGiftExperimentOverride, 0, len(overrides))
for _, override := range overrides {
out = append(out, &luckygiftv1.LuckyGiftExperimentOverride{
UserKey: strings.TrimSpace(override.UserKey),
Arm: strings.ToLower(strings.TrimSpace(override.Arm)),
})
}
return out
}
func experimentFromProto(experiment *luckygiftv1.LuckyGiftExperiment) experimentDTO {
if experiment == nil {
return experimentDTO{Overrides: []experimentOverrideDTO{}, ArmStats: []experimentArmStatsDTO{}}
}
overrides := make([]experimentOverrideDTO, 0, len(experiment.GetOverrides()))
for _, override := range experiment.GetOverrides() {
overrides = append(overrides, experimentOverrideDTO{
UserKey: override.GetUserKey(),
Arm: override.GetArm(),
})
}
armStats := make([]experimentArmStatsDTO, 0, len(experiment.GetArmStats()))
for _, stats := range experiment.GetArmStats() {
armStats = append(armStats, experimentArmStatsDTO{
Arm: stats.GetArm(),
RuleVersion: stats.GetRuleVersion(),
StrategyVersion: stats.GetStrategyVersion(),
TotalDraws: stats.GetTotalDraws(),
UniqueUsers: stats.GetUniqueUsers(),
TotalSpentCoins: stats.GetTotalSpentCoins(),
TotalRewardCoins: stats.GetTotalRewardCoins(),
ActualRTPPercent: ppmToPercent(stats.GetActualRtpPpm()),
})
}
return experimentDTO{
AppCode: experiment.GetAppCode(),
PoolID: experiment.GetPoolId(),
ExperimentID: experiment.GetExperimentId(),
Name: experiment.GetName(),
ControlRuleVersion: experiment.GetControlRuleVersion(),
TreatmentRuleVersion: experiment.GetTreatmentRuleVersion(),
TreatmentTrafficPercent: ppmToPercent(experiment.GetTreatmentTrafficPpm()),
Status: experiment.GetStatus(),
WinnerArm: experiment.GetWinnerArm(),
WinnerRuleVersion: experiment.GetWinnerRuleVersion(),
FinalRuleVersion: experiment.GetFinalRuleVersion(),
CreatedByAdminID: experiment.GetCreatedByAdminId(),
StartedAtMS: experiment.GetStartedAtMs(),
EndedAtMS: experiment.GetEndedAtMs(),
UpdatedAtMS: experiment.GetUpdatedAtMs(),
Overrides: overrides,
ArmStats: armStats,
}
}