- lucky_gift_rule_versions 新增 v2_high_water_boost_{threshold,factor_ppm,recover}_coins,0 表示关闭
- lucky_pools 新增 v2_boost_active 滞回标志:触发阈值后加权,跌破恢复水位退出
- 迁移 010 全部 ALGORITHM=INSTANT,不回写历史行;proto/admin 透传三元组
- 真库回归 lucky_gift_v2_boost_test.go
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
145 lines
6.5 KiB
Go
145 lines
6.5 KiB
Go
package luckygift
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||
)
|
||
|
||
// CreateExperiment 原子完成 treatment 发布与实验登记。treatment 配置必须直接可运行(enabled),
|
||
// 否则实验组用户在送礼预检查就会被 RuleNotActive 拦截,实验退化成按人关停功能。
|
||
func (s *Service) CreateExperiment(ctx context.Context, cmd domain.CreateExperimentCommand) (domain.Experiment, domain.RuleConfig, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||
}
|
||
cmd.Name = strings.TrimSpace(cmd.Name)
|
||
if cmd.Name == "" || utf8.RuneCountInString(cmd.Name) > 64 {
|
||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment name must be 1-64 characters")
|
||
}
|
||
if cmd.TreatmentTrafficPPM < 0 || cmd.TreatmentTrafficPPM > domain.ExperimentTrafficPPMScale {
|
||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment treatment traffic must be between 0 and 1000000 ppm")
|
||
}
|
||
cmd.TreatmentConfig.AppCode = appcode.FromContext(ctx)
|
||
cmd.TreatmentConfig.PoolID = normalizePoolID(cmd.TreatmentConfig.PoolID)
|
||
cmd.TreatmentConfig = normalizeRuleConfig(cmd.TreatmentConfig)
|
||
if !cmd.TreatmentConfig.Enabled {
|
||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment treatment config must be enabled")
|
||
}
|
||
if err := validateRuleConfig(cmd.TreatmentConfig); err != nil {
|
||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||
}
|
||
overrides, err := normalizeExperimentOverrides(cmd.Overrides)
|
||
if err != nil {
|
||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||
}
|
||
cmd.Overrides = overrides
|
||
return s.repository.CreateLuckyGiftExperiment(ctx, cmd, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) ListExperiments(ctx context.Context, query domain.ExperimentQuery) ([]domain.Experiment, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(query.AppCode) == "" {
|
||
query.AppCode = appcode.FromContext(ctx)
|
||
}
|
||
if status := strings.ToLower(strings.TrimSpace(query.Status)); status != "" {
|
||
if status != domain.ExperimentStatusRunning && status != domain.ExperimentStatusPaused && status != domain.ExperimentStatusCompleted {
|
||
return nil, xerr.New(xerr.InvalidArgument, "invalid experiment status filter")
|
||
}
|
||
query.Status = status
|
||
}
|
||
return s.repository.ListLuckyGiftExperiments(ctx, query)
|
||
}
|
||
|
||
func (s *Service) UpdateExperiment(ctx context.Context, cmd domain.UpdateExperimentCommand) (domain.Experiment, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Experiment{}, err
|
||
}
|
||
cmd.ExperimentID = strings.TrimSpace(cmd.ExperimentID)
|
||
if cmd.ExperimentID == "" {
|
||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment id is required")
|
||
}
|
||
if cmd.TreatmentTrafficPPM > domain.ExperimentTrafficPPMScale {
|
||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment treatment traffic must be between 0 and 1000000 ppm")
|
||
}
|
||
cmd.Status = strings.ToLower(strings.TrimSpace(cmd.Status))
|
||
if cmd.Status != "" && cmd.Status != domain.ExperimentStatusRunning && cmd.Status != domain.ExperimentStatusPaused {
|
||
// completed 只能经 EndExperiment 收口:结束必须同时重发布获胜版本,不能只改状态。
|
||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment status can only switch between running and paused")
|
||
}
|
||
if cmd.TreatmentTrafficPPM < 0 && cmd.Status == "" {
|
||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment update has nothing to change")
|
||
}
|
||
return s.repository.UpdateLuckyGiftExperiment(ctx, cmd, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) SetExperimentOverrides(ctx context.Context, cmd domain.SetExperimentOverridesCommand) (domain.Experiment, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Experiment{}, err
|
||
}
|
||
cmd.ExperimentID = strings.TrimSpace(cmd.ExperimentID)
|
||
if cmd.ExperimentID == "" {
|
||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment id is required")
|
||
}
|
||
upserts, err := normalizeExperimentOverrides(cmd.Upserts)
|
||
if err != nil {
|
||
return domain.Experiment{}, err
|
||
}
|
||
cmd.Upserts = upserts
|
||
removes := make([]string, 0, len(cmd.Removes))
|
||
for _, userKey := range cmd.Removes {
|
||
if userKey = strings.TrimSpace(userKey); userKey != "" {
|
||
removes = append(removes, userKey)
|
||
}
|
||
}
|
||
cmd.Removes = removes
|
||
if len(cmd.Upserts) == 0 && len(cmd.Removes) == 0 {
|
||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment override update has nothing to change")
|
||
}
|
||
return s.repository.SetLuckyGiftExperimentOverrides(ctx, cmd, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) EndExperiment(ctx context.Context, cmd domain.EndExperimentCommand) (domain.Experiment, domain.RuleConfig, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||
}
|
||
cmd.ExperimentID = strings.TrimSpace(cmd.ExperimentID)
|
||
if cmd.ExperimentID == "" {
|
||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment id is required")
|
||
}
|
||
cmd.WinnerArm = strings.ToLower(strings.TrimSpace(cmd.WinnerArm))
|
||
if !domain.ValidExperimentArm(cmd.WinnerArm) {
|
||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment winner arm must be control or treatment")
|
||
}
|
||
return s.repository.EndLuckyGiftExperiment(ctx, cmd, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func normalizeExperimentOverrides(overrides []domain.ExperimentOverride) ([]domain.ExperimentOverride, error) {
|
||
if len(overrides) > domain.MaxExperimentOverrides {
|
||
return nil, xerr.New(xerr.InvalidArgument, "experiment overrides exceed the 500 user limit")
|
||
}
|
||
seen := make(map[string]struct{}, len(overrides))
|
||
out := make([]domain.ExperimentOverride, 0, len(overrides))
|
||
for _, override := range overrides {
|
||
override.UserKey = strings.TrimSpace(override.UserKey)
|
||
override.Arm = strings.ToLower(strings.TrimSpace(override.Arm))
|
||
if override.UserKey == "" || utf8.RuneCountInString(override.UserKey) > 128 {
|
||
return nil, xerr.New(xerr.InvalidArgument, "experiment override user key must be 1-128 characters")
|
||
}
|
||
if !domain.ValidExperimentArm(override.Arm) {
|
||
return nil, xerr.New(xerr.InvalidArgument, "experiment override arm must be control or treatment")
|
||
}
|
||
if _, exists := seen[override.UserKey]; exists {
|
||
return nil, xerr.New(xerr.InvalidArgument, "experiment override user key is duplicated: "+override.UserKey)
|
||
}
|
||
seen[override.UserKey] = struct{}{}
|
||
out = append(out, override)
|
||
}
|
||
return out, nil
|
||
}
|