zhx dc8435f869 增加幸运礼物 AB 实验:按用户哈希分流灰度发布配置版本
- 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>
2026-07-17 00:22:12 +08:00

146 lines
6.5 KiB
Go
Raw 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
}