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 }