- 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>
313 lines
12 KiB
Go
313 lines
12 KiB
Go
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,
|
||
}
|
||
}
|