zhx 5da1421fae 幸运礼物 fixed_v2 高水位返奖加权:阈值/系数/恢复水位三元组与奖池滞回标志
- 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>
2026-07-17 01:35:35 +08:00

145 lines
6.5 KiB
Go
Raw Permalink 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 (
"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
}