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

278 lines
12 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 (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"hyapp-admin-server/internal/integration/luckygiftadmin"
"hyapp-admin-server/internal/middleware"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
"github.com/gin-gonic/gin"
)
type experimentClientStub struct {
luckygiftadmin.Client
createRequest *luckygiftv1.CreateLuckyGiftExperimentRequest
createResponse *luckygiftv1.CreateLuckyGiftExperimentResponse
updateRequest *luckygiftv1.UpdateLuckyGiftExperimentRequest
updateResponse *luckygiftv1.UpdateLuckyGiftExperimentResponse
overridesRequest *luckygiftv1.SetLuckyGiftExperimentOverridesRequest
endRequest *luckygiftv1.EndLuckyGiftExperimentRequest
endResponse *luckygiftv1.EndLuckyGiftExperimentResponse
err error
}
func (s *experimentClientStub) CreateLuckyGiftExperiment(_ context.Context, request *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
s.createRequest = request
return s.createResponse, s.err
}
func (s *experimentClientStub) UpdateLuckyGiftExperiment(_ context.Context, request *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
s.updateRequest = request
return s.updateResponse, s.err
}
func (s *experimentClientStub) SetLuckyGiftExperimentOverrides(_ context.Context, request *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) {
s.overridesRequest = request
return &luckygiftv1.SetLuckyGiftExperimentOverridesResponse{
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: request.GetExperimentId(), Overrides: request.GetUpserts()},
}, s.err
}
func (s *experimentClientStub) EndLuckyGiftExperiment(_ context.Context, request *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) {
s.endRequest = request
return s.endResponse, s.err
}
func experimentTestContext(t *testing.T, method, target, body string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(method, target, bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(middleware.ContextUserID, uint(7))
c.Set(middleware.ContextRequestID, "request-1")
return c, recorder
}
func TestCreateLuckyGiftExperimentReusesConfigNormalizationAndPPM(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &experimentClientStub{createResponse: &luckygiftv1.CreateLuckyGiftExperimentResponse{
Experiment: &luckygiftv1.LuckyGiftExperiment{
AppCode: "lalu", PoolId: "super_lucky", ExperimentId: "lucky_exp_1", Name: "rtp push",
ControlRuleVersion: 9, TreatmentRuleVersion: 10, TreatmentTrafficPpm: 50_000, Status: "running",
Overrides: []*luckygiftv1.LuckyGiftExperimentOverride{{UserKey: "1001", Arm: "treatment"}},
ArmStats: []*luckygiftv1.LuckyGiftExperimentArmStats{{Arm: "control", RuleVersion: 9, ActualRtpPpm: 955_000}},
},
TreatmentConfig: &luckygiftv1.LuckyGiftRuleConfig{AppCode: "lalu", PoolId: "super_lucky", RuleVersion: 10, TargetRtpPpm: 960_000},
}}
body := `{
"name": " rtp push ",
"treatment_traffic_percent": 5,
"overrides": [{"user_key": " 1001 ", "arm": " TREATMENT "}],
"config": {
"pool_id": "super_lucky",
"strategy_version": "DYNAMIC_V3",
"enabled": true,
"target_rtp_percent": 96,
"initial_pool_coins": 1000000,
"stages": [{"stage": "normal", "tiers": [{"multiplier": 0.5, "probability_percent": 22, "enabled": true}]}]
}
}`
c, recorder := experimentTestContext(t, http.MethodPost, "/admin/ops-center/lucky-gifts/experiments?app_code=LALU", body)
New(client, time.Second, nil).CreateLuckyGiftExperiment(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
request := client.createRequest
if request == nil {
t.Fatal("owner create request was not sent")
}
if request.GetName() != "rtp push" || request.GetTreatmentTrafficPpm() != 50_000 || request.GetOperatorAdminId() != 7 {
t.Fatalf("owner request=%+v", request)
}
if request.GetMeta().GetAppCode() != "lalu" || request.GetMeta().GetRequestId() != "request-1" {
t.Fatalf("owner request meta=%+v", request.GetMeta())
}
if len(request.GetOverrides()) != 1 || request.GetOverrides()[0].GetUserKey() != "1001" || request.GetOverrides()[0].GetArm() != "treatment" {
t.Fatalf("owner request overrides=%+v", request.GetOverrides())
}
config := request.GetTreatmentConfig()
if config.GetAppCode() != "lalu" || config.GetStrategyVersion() != "dynamic_v3" {
t.Fatalf("treatment config app/strategy=%+v", config)
}
if config.GetTargetRtpPpm() != 960_000 {
t.Fatalf("treatment config target rtp ppm=%d, want 960000", config.GetTargetRtpPpm())
}
if config.GetInitialPoolCoins() != 0 {
t.Fatalf("dynamic_v3 initial pool coins should be forced to 0, got %d", config.GetInitialPoolCoins())
}
tier := config.GetStages()[0].GetTiers()[0]
if tier.GetTierId() != "normal_0_5x" || tier.GetBaseWeightPpm() != 220_000 {
t.Fatalf("treatment config tier=%+v", tier)
}
var envelope struct {
Code int `json:"code"`
Data struct {
Experiment struct {
ExperimentID string `json:"experiment_id"`
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
ArmStats []struct {
Arm string `json:"arm"`
ActualRTPPercent float64 `json:"actual_rtp_percent"`
} `json:"arm_stats"`
} `json:"experiment"`
TreatmentConfig struct {
RuleVersion int64 `json:"rule_version"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
} `json:"treatment_config"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != 0 || envelope.Data.Experiment.ExperimentID != "lucky_exp_1" || envelope.Data.Experiment.TreatmentTrafficPercent != 5 {
t.Fatalf("response=%s", recorder.Body.String())
}
if len(envelope.Data.Experiment.ArmStats) != 1 || envelope.Data.Experiment.ArmStats[0].ActualRTPPercent != 95.5 {
t.Fatalf("arm stats response=%s", recorder.Body.String())
}
if envelope.Data.TreatmentConfig.RuleVersion != 10 || envelope.Data.TreatmentConfig.TargetRTPPercent != 96 {
t.Fatalf("treatment config response=%s", recorder.Body.String())
}
}
func TestUpdateLuckyGiftExperimentOmittedFieldsKeepOwnerDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
// 缺省流量以 proto3 optional 的"字段不存在"表达wantTraffic=nil显式 0 必须以存在的 0 传递;
// 这是防止"仅改状态的调用把放量静默清零"的契约核心。
tests := []struct {
name string
body string
wantTraffic *int64
wantStatus string
}{
{name: "omitted fields", body: `{}`, wantTraffic: nil, wantStatus: ""},
{name: "explicit zero traffic", body: `{"treatment_traffic_percent": 0}`, wantTraffic: int64Ptr(0), wantStatus: ""},
{name: "pause only", body: `{"status": "paused"}`, wantTraffic: nil, wantStatus: "paused"},
{name: "both fields", body: `{"treatment_traffic_percent": 12.5, "status": "running"}`, wantTraffic: int64Ptr(125_000), wantStatus: "running"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &experimentClientStub{updateResponse: &luckygiftv1.UpdateLuckyGiftExperimentResponse{
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: "lucky_exp_1", Status: "running"},
}}
c, recorder := experimentTestContext(t, http.MethodPatch, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1?app_code=lalu", tt.body)
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
New(client, time.Second, nil).UpdateLuckyGiftExperiment(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
request := client.updateRequest
if request == nil || request.GetExperimentId() != "lucky_exp_1" || request.GetOperatorAdminId() != 7 {
t.Fatalf("owner request=%+v", request)
}
if (request.TreatmentTrafficPpm == nil) != (tt.wantTraffic == nil) ||
(tt.wantTraffic != nil && request.GetTreatmentTrafficPpm() != *tt.wantTraffic) ||
request.GetStatus() != tt.wantStatus {
t.Fatalf("traffic=%v status=%q, want traffic=%v status=%q", request.TreatmentTrafficPpm, request.GetStatus(), tt.wantTraffic, tt.wantStatus)
}
})
}
}
func int64Ptr(v int64) *int64 { return &v }
func TestSetLuckyGiftExperimentOverridesForwardsUpsertsAndRemoves(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &experimentClientStub{}
body := `{"upserts": [{"user_key": "ext:abc", "arm": "control"}], "removes": [" 1001 "]}`
c, recorder := experimentTestContext(t, http.MethodPut, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1/overrides?app_code=lalu", body)
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
New(client, time.Second, nil).SetLuckyGiftExperimentOverrides(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
request := client.overridesRequest
if request == nil || request.GetExperimentId() != "lucky_exp_1" || request.GetOperatorAdminId() != 7 {
t.Fatalf("owner request=%+v", request)
}
if len(request.GetUpserts()) != 1 || request.GetUpserts()[0].GetUserKey() != "ext:abc" || request.GetUpserts()[0].GetArm() != "control" {
t.Fatalf("owner upserts=%+v", request.GetUpserts())
}
if len(request.GetRemoves()) != 1 || request.GetRemoves()[0] != "1001" {
t.Fatalf("owner removes=%+v", request.GetRemoves())
}
var envelope struct {
Data struct {
Experiment struct {
ExperimentID string `json:"experiment_id"`
} `json:"experiment"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Data.Experiment.ExperimentID != "lucky_exp_1" {
t.Fatalf("response=%s", recorder.Body.String())
}
}
func TestEndLuckyGiftExperimentReturnsExperimentAndFinalConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &experimentClientStub{endResponse: &luckygiftv1.EndLuckyGiftExperimentResponse{
Experiment: &luckygiftv1.LuckyGiftExperiment{
ExperimentId: "lucky_exp_1", Status: "completed", WinnerArm: "treatment",
WinnerRuleVersion: 10, FinalRuleVersion: 11,
},
FinalConfig: &luckygiftv1.LuckyGiftRuleConfig{PoolId: "super_lucky", RuleVersion: 11, TargetRtpPpm: 960_000},
}}
c, recorder := experimentTestContext(t, http.MethodPost, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1/end?app_code=lalu", `{"winner_arm": "Treatment"}`)
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
New(client, time.Second, nil).EndLuckyGiftExperiment(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if client.endRequest == nil || client.endRequest.GetWinnerArm() != "treatment" || client.endRequest.GetOperatorAdminId() != 7 {
t.Fatalf("owner request=%+v", client.endRequest)
}
var envelope struct {
Code int `json:"code"`
Data struct {
Experiment struct {
Status string `json:"status"`
WinnerArm string `json:"winner_arm"`
FinalRuleVersion int64 `json:"final_rule_version"`
} `json:"experiment"`
FinalConfig struct {
RuleVersion int64 `json:"rule_version"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
} `json:"final_config"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != 0 || envelope.Data.Experiment.Status != "completed" || envelope.Data.Experiment.WinnerArm != "treatment" || envelope.Data.Experiment.FinalRuleVersion != 11 {
t.Fatalf("experiment response=%s", recorder.Body.String())
}
if envelope.Data.FinalConfig.RuleVersion != 11 || envelope.Data.FinalConfig.TargetRTPPercent != 96 {
t.Fatalf("final config response=%s", recorder.Body.String())
}
}
func TestListLuckyGiftExperimentsRequiresAppCode(t *testing.T) {
gin.SetMode(gin.TestMode)
c, recorder := experimentTestContext(t, http.MethodGet, "/admin/ops-center/lucky-gifts/experiments", "")
New(&experimentClientStub{}, time.Second, nil).ListLuckyGiftExperiments(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status=%d want=400 body=%s", recorder.Code, recorder.Body.String())
}
}