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, } }