diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index bbe62895..734b60cc 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.29.2 +// protoc v7.35.0 // source: proto/luckygift/v1/luckygift.proto package luckygiftv1 @@ -2626,6 +2626,962 @@ func (x *ListLuckyGiftPoolBalancesResponse) GetPools() []*LuckyGiftPoolBalance { return nil } +// LuckyGiftExperimentOverride 把单个用户强制固定到某个实验组,用于放量前的白名单验证。 +// user_key:内部用户为十进制 user_id 字符串;外部用户为 "ext:" + external_user_id。 +type LuckyGiftExperimentOverride struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserKey string `protobuf:"bytes,1,opt,name=user_key,json=userKey,proto3" json:"user_key,omitempty"` + Arm string `protobuf:"bytes,2,opt,name=arm,proto3" json:"arm,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LuckyGiftExperimentOverride) Reset() { + *x = LuckyGiftExperimentOverride{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftExperimentOverride) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftExperimentOverride) ProtoMessage() {} + +func (x *LuckyGiftExperimentOverride) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LuckyGiftExperimentOverride.ProtoReflect.Descriptor instead. +func (*LuckyGiftExperimentOverride) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{28} +} + +func (x *LuckyGiftExperimentOverride) GetUserKey() string { + if x != nil { + return x.UserKey + } + return "" +} + +func (x *LuckyGiftExperimentOverride) GetArm() string { + if x != nil { + return x.Arm + } + return "" +} + +// LuckyGiftExperimentArmStats 是实验开始以来单组的抽奖聚合,由 lucky_draw_records 按 rule_version 现算,非物化口径。 +type LuckyGiftExperimentArmStats struct { + state protoimpl.MessageState `protogen:"open.v1"` + Arm string `protobuf:"bytes,1,opt,name=arm,proto3" json:"arm,omitempty"` + RuleVersion int64 `protobuf:"varint,2,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"` + StrategyVersion string `protobuf:"bytes,3,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"` + TotalDraws int64 `protobuf:"varint,4,opt,name=total_draws,json=totalDraws,proto3" json:"total_draws,omitempty"` + UniqueUsers int64 `protobuf:"varint,5,opt,name=unique_users,json=uniqueUsers,proto3" json:"unique_users,omitempty"` + TotalSpentCoins int64 `protobuf:"varint,6,opt,name=total_spent_coins,json=totalSpentCoins,proto3" json:"total_spent_coins,omitempty"` + TotalRewardCoins int64 `protobuf:"varint,7,opt,name=total_reward_coins,json=totalRewardCoins,proto3" json:"total_reward_coins,omitempty"` + ActualRtpPpm int64 `protobuf:"varint,8,opt,name=actual_rtp_ppm,json=actualRtpPpm,proto3" json:"actual_rtp_ppm,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LuckyGiftExperimentArmStats) Reset() { + *x = LuckyGiftExperimentArmStats{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftExperimentArmStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftExperimentArmStats) ProtoMessage() {} + +func (x *LuckyGiftExperimentArmStats) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LuckyGiftExperimentArmStats.ProtoReflect.Descriptor instead. +func (*LuckyGiftExperimentArmStats) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{29} +} + +func (x *LuckyGiftExperimentArmStats) GetArm() string { + if x != nil { + return x.Arm + } + return "" +} + +func (x *LuckyGiftExperimentArmStats) GetRuleVersion() int64 { + if x != nil { + return x.RuleVersion + } + return 0 +} + +func (x *LuckyGiftExperimentArmStats) GetStrategyVersion() string { + if x != nil { + return x.StrategyVersion + } + return "" +} + +func (x *LuckyGiftExperimentArmStats) GetTotalDraws() int64 { + if x != nil { + return x.TotalDraws + } + return 0 +} + +func (x *LuckyGiftExperimentArmStats) GetUniqueUsers() int64 { + if x != nil { + return x.UniqueUsers + } + return 0 +} + +func (x *LuckyGiftExperimentArmStats) GetTotalSpentCoins() int64 { + if x != nil { + return x.TotalSpentCoins + } + return 0 +} + +func (x *LuckyGiftExperimentArmStats) GetTotalRewardCoins() int64 { + if x != nil { + return x.TotalRewardCoins + } + return 0 +} + +func (x *LuckyGiftExperimentArmStats) GetActualRtpPpm() int64 { + if x != nil { + return x.ActualRtpPpm + } + return 0 +} + +// LuckyGiftExperiment 是单奖池 AB 实验事实:两组各钉住一个不可变 rule_version, +// 运行时按 bucket_salt+user 哈希分流;同一奖池同时最多一个未完成实验。 +type LuckyGiftExperiment struct { + state protoimpl.MessageState `protogen:"open.v1"` + AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + ExperimentId string `protobuf:"bytes,3,opt,name=experiment_id,json=experimentId,proto3" json:"experiment_id,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + ControlRuleVersion int64 `protobuf:"varint,5,opt,name=control_rule_version,json=controlRuleVersion,proto3" json:"control_rule_version,omitempty"` + TreatmentRuleVersion int64 `protobuf:"varint,6,opt,name=treatment_rule_version,json=treatmentRuleVersion,proto3" json:"treatment_rule_version,omitempty"` + // treatment 组流量比例(ppm,0..1000000);0 表示只有白名单 override 能进入 treatment。 + TreatmentTrafficPpm int64 `protobuf:"varint,7,opt,name=treatment_traffic_ppm,json=treatmentTrafficPpm,proto3" json:"treatment_traffic_ppm,omitempty"` + BucketSalt string `protobuf:"bytes,8,opt,name=bucket_salt,json=bucketSalt,proto3" json:"bucket_salt,omitempty"` + // running / paused / completed;paused 时全部流量(含白名单)回落 control。 + Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"` + WinnerArm string `protobuf:"bytes,10,opt,name=winner_arm,json=winnerArm,proto3" json:"winner_arm,omitempty"` + WinnerRuleVersion int64 `protobuf:"varint,11,opt,name=winner_rule_version,json=winnerRuleVersion,proto3" json:"winner_rule_version,omitempty"` + // 结束实验时把获胜配置重发布成的新最新版本,全量流量由它承接。 + FinalRuleVersion int64 `protobuf:"varint,12,opt,name=final_rule_version,json=finalRuleVersion,proto3" json:"final_rule_version,omitempty"` + CreatedByAdminId int64 `protobuf:"varint,13,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"` + StartedAtMs int64 `protobuf:"varint,14,opt,name=started_at_ms,json=startedAtMs,proto3" json:"started_at_ms,omitempty"` + EndedAtMs int64 `protobuf:"varint,15,opt,name=ended_at_ms,json=endedAtMs,proto3" json:"ended_at_ms,omitempty"` + UpdatedAtMs int64 `protobuf:"varint,16,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + Overrides []*LuckyGiftExperimentOverride `protobuf:"bytes,17,rep,name=overrides,proto3" json:"overrides,omitempty"` + ArmStats []*LuckyGiftExperimentArmStats `protobuf:"bytes,18,rep,name=arm_stats,json=armStats,proto3" json:"arm_stats,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LuckyGiftExperiment) Reset() { + *x = LuckyGiftExperiment{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftExperiment) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftExperiment) ProtoMessage() {} + +func (x *LuckyGiftExperiment) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LuckyGiftExperiment.ProtoReflect.Descriptor instead. +func (*LuckyGiftExperiment) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{30} +} + +func (x *LuckyGiftExperiment) GetAppCode() string { + if x != nil { + return x.AppCode + } + return "" +} + +func (x *LuckyGiftExperiment) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + +func (x *LuckyGiftExperiment) GetExperimentId() string { + if x != nil { + return x.ExperimentId + } + return "" +} + +func (x *LuckyGiftExperiment) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *LuckyGiftExperiment) GetControlRuleVersion() int64 { + if x != nil { + return x.ControlRuleVersion + } + return 0 +} + +func (x *LuckyGiftExperiment) GetTreatmentRuleVersion() int64 { + if x != nil { + return x.TreatmentRuleVersion + } + return 0 +} + +func (x *LuckyGiftExperiment) GetTreatmentTrafficPpm() int64 { + if x != nil { + return x.TreatmentTrafficPpm + } + return 0 +} + +func (x *LuckyGiftExperiment) GetBucketSalt() string { + if x != nil { + return x.BucketSalt + } + return "" +} + +func (x *LuckyGiftExperiment) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *LuckyGiftExperiment) GetWinnerArm() string { + if x != nil { + return x.WinnerArm + } + return "" +} + +func (x *LuckyGiftExperiment) GetWinnerRuleVersion() int64 { + if x != nil { + return x.WinnerRuleVersion + } + return 0 +} + +func (x *LuckyGiftExperiment) GetFinalRuleVersion() int64 { + if x != nil { + return x.FinalRuleVersion + } + return 0 +} + +func (x *LuckyGiftExperiment) GetCreatedByAdminId() int64 { + if x != nil { + return x.CreatedByAdminId + } + return 0 +} + +func (x *LuckyGiftExperiment) GetStartedAtMs() int64 { + if x != nil { + return x.StartedAtMs + } + return 0 +} + +func (x *LuckyGiftExperiment) GetEndedAtMs() int64 { + if x != nil { + return x.EndedAtMs + } + return 0 +} + +func (x *LuckyGiftExperiment) GetUpdatedAtMs() int64 { + if x != nil { + return x.UpdatedAtMs + } + return 0 +} + +func (x *LuckyGiftExperiment) GetOverrides() []*LuckyGiftExperimentOverride { + if x != nil { + return x.Overrides + } + return nil +} + +func (x *LuckyGiftExperiment) GetArmStats() []*LuckyGiftExperimentArmStats { + if x != nil { + return x.ArmStats + } + return nil +} + +// CreateLuckyGiftExperimentRequest 在一个事务里发布 treatment 新版本并登记实验, +// 避免"先发布后建实验"窗口期内新版本被最新版本语义全量放开。 +// control 组自动钉住当前最新已启用版本。 +type CreateLuckyGiftExperimentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + TreatmentConfig *LuckyGiftRuleConfig `protobuf:"bytes,3,opt,name=treatment_config,json=treatmentConfig,proto3" json:"treatment_config,omitempty"` + TreatmentTrafficPpm int64 `protobuf:"varint,4,opt,name=treatment_traffic_ppm,json=treatmentTrafficPpm,proto3" json:"treatment_traffic_ppm,omitempty"` + OperatorAdminId int64 `protobuf:"varint,5,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"` + Overrides []*LuckyGiftExperimentOverride `protobuf:"bytes,6,rep,name=overrides,proto3" json:"overrides,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateLuckyGiftExperimentRequest) Reset() { + *x = CreateLuckyGiftExperimentRequest{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateLuckyGiftExperimentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateLuckyGiftExperimentRequest) ProtoMessage() {} + +func (x *CreateLuckyGiftExperimentRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateLuckyGiftExperimentRequest.ProtoReflect.Descriptor instead. +func (*CreateLuckyGiftExperimentRequest) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{31} +} + +func (x *CreateLuckyGiftExperimentRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *CreateLuckyGiftExperimentRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateLuckyGiftExperimentRequest) GetTreatmentConfig() *LuckyGiftRuleConfig { + if x != nil { + return x.TreatmentConfig + } + return nil +} + +func (x *CreateLuckyGiftExperimentRequest) GetTreatmentTrafficPpm() int64 { + if x != nil { + return x.TreatmentTrafficPpm + } + return 0 +} + +func (x *CreateLuckyGiftExperimentRequest) GetOperatorAdminId() int64 { + if x != nil { + return x.OperatorAdminId + } + return 0 +} + +func (x *CreateLuckyGiftExperimentRequest) GetOverrides() []*LuckyGiftExperimentOverride { + if x != nil { + return x.Overrides + } + return nil +} + +type CreateLuckyGiftExperimentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Experiment *LuckyGiftExperiment `protobuf:"bytes,1,opt,name=experiment,proto3" json:"experiment,omitempty"` + TreatmentConfig *LuckyGiftRuleConfig `protobuf:"bytes,2,opt,name=treatment_config,json=treatmentConfig,proto3" json:"treatment_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateLuckyGiftExperimentResponse) Reset() { + *x = CreateLuckyGiftExperimentResponse{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateLuckyGiftExperimentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateLuckyGiftExperimentResponse) ProtoMessage() {} + +func (x *CreateLuckyGiftExperimentResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateLuckyGiftExperimentResponse.ProtoReflect.Descriptor instead. +func (*CreateLuckyGiftExperimentResponse) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{32} +} + +func (x *CreateLuckyGiftExperimentResponse) GetExperiment() *LuckyGiftExperiment { + if x != nil { + return x.Experiment + } + return nil +} + +func (x *CreateLuckyGiftExperimentResponse) GetTreatmentConfig() *LuckyGiftRuleConfig { + if x != nil { + return x.TreatmentConfig + } + return nil +} + +type ListLuckyGiftExperimentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + WithStats bool `protobuf:"varint,4,opt,name=with_stats,json=withStats,proto3" json:"with_stats,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListLuckyGiftExperimentsRequest) Reset() { + *x = ListLuckyGiftExperimentsRequest{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListLuckyGiftExperimentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListLuckyGiftExperimentsRequest) ProtoMessage() {} + +func (x *ListLuckyGiftExperimentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListLuckyGiftExperimentsRequest.ProtoReflect.Descriptor instead. +func (*ListLuckyGiftExperimentsRequest) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{33} +} + +func (x *ListLuckyGiftExperimentsRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ListLuckyGiftExperimentsRequest) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + +func (x *ListLuckyGiftExperimentsRequest) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ListLuckyGiftExperimentsRequest) GetWithStats() bool { + if x != nil { + return x.WithStats + } + return false +} + +type ListLuckyGiftExperimentsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Experiments []*LuckyGiftExperiment `protobuf:"bytes,1,rep,name=experiments,proto3" json:"experiments,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListLuckyGiftExperimentsResponse) Reset() { + *x = ListLuckyGiftExperimentsResponse{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListLuckyGiftExperimentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListLuckyGiftExperimentsResponse) ProtoMessage() {} + +func (x *ListLuckyGiftExperimentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListLuckyGiftExperimentsResponse.ProtoReflect.Descriptor instead. +func (*ListLuckyGiftExperimentsResponse) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{34} +} + +func (x *ListLuckyGiftExperimentsResponse) GetExperiments() []*LuckyGiftExperiment { + if x != nil { + return x.Experiments + } + return nil +} + +// UpdateLuckyGiftExperimentRequest 调整放量或暂停/恢复。 +// treatment_traffic_ppm 使用显式存在性语义:未设置表示不修改流量,避免 proto3 缺省 0 被误解释成"放量清零"; +// status 为空表示不修改状态。 +type UpdateLuckyGiftExperimentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + ExperimentId string `protobuf:"bytes,2,opt,name=experiment_id,json=experimentId,proto3" json:"experiment_id,omitempty"` + TreatmentTrafficPpm *int64 `protobuf:"varint,3,opt,name=treatment_traffic_ppm,json=treatmentTrafficPpm,proto3,oneof" json:"treatment_traffic_ppm,omitempty"` + Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` + OperatorAdminId int64 `protobuf:"varint,5,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateLuckyGiftExperimentRequest) Reset() { + *x = UpdateLuckyGiftExperimentRequest{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateLuckyGiftExperimentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateLuckyGiftExperimentRequest) ProtoMessage() {} + +func (x *UpdateLuckyGiftExperimentRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateLuckyGiftExperimentRequest.ProtoReflect.Descriptor instead. +func (*UpdateLuckyGiftExperimentRequest) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{35} +} + +func (x *UpdateLuckyGiftExperimentRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *UpdateLuckyGiftExperimentRequest) GetExperimentId() string { + if x != nil { + return x.ExperimentId + } + return "" +} + +func (x *UpdateLuckyGiftExperimentRequest) GetTreatmentTrafficPpm() int64 { + if x != nil && x.TreatmentTrafficPpm != nil { + return *x.TreatmentTrafficPpm + } + return 0 +} + +func (x *UpdateLuckyGiftExperimentRequest) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *UpdateLuckyGiftExperimentRequest) GetOperatorAdminId() int64 { + if x != nil { + return x.OperatorAdminId + } + return 0 +} + +type UpdateLuckyGiftExperimentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Experiment *LuckyGiftExperiment `protobuf:"bytes,1,opt,name=experiment,proto3" json:"experiment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateLuckyGiftExperimentResponse) Reset() { + *x = UpdateLuckyGiftExperimentResponse{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateLuckyGiftExperimentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateLuckyGiftExperimentResponse) ProtoMessage() {} + +func (x *UpdateLuckyGiftExperimentResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateLuckyGiftExperimentResponse.ProtoReflect.Descriptor instead. +func (*UpdateLuckyGiftExperimentResponse) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{36} +} + +func (x *UpdateLuckyGiftExperimentResponse) GetExperiment() *LuckyGiftExperiment { + if x != nil { + return x.Experiment + } + return nil +} + +type SetLuckyGiftExperimentOverridesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + ExperimentId string `protobuf:"bytes,2,opt,name=experiment_id,json=experimentId,proto3" json:"experiment_id,omitempty"` + Upserts []*LuckyGiftExperimentOverride `protobuf:"bytes,3,rep,name=upserts,proto3" json:"upserts,omitempty"` + Removes []string `protobuf:"bytes,4,rep,name=removes,proto3" json:"removes,omitempty"` + OperatorAdminId int64 `protobuf:"varint,5,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetLuckyGiftExperimentOverridesRequest) Reset() { + *x = SetLuckyGiftExperimentOverridesRequest{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetLuckyGiftExperimentOverridesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetLuckyGiftExperimentOverridesRequest) ProtoMessage() {} + +func (x *SetLuckyGiftExperimentOverridesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetLuckyGiftExperimentOverridesRequest.ProtoReflect.Descriptor instead. +func (*SetLuckyGiftExperimentOverridesRequest) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{37} +} + +func (x *SetLuckyGiftExperimentOverridesRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *SetLuckyGiftExperimentOverridesRequest) GetExperimentId() string { + if x != nil { + return x.ExperimentId + } + return "" +} + +func (x *SetLuckyGiftExperimentOverridesRequest) GetUpserts() []*LuckyGiftExperimentOverride { + if x != nil { + return x.Upserts + } + return nil +} + +func (x *SetLuckyGiftExperimentOverridesRequest) GetRemoves() []string { + if x != nil { + return x.Removes + } + return nil +} + +func (x *SetLuckyGiftExperimentOverridesRequest) GetOperatorAdminId() int64 { + if x != nil { + return x.OperatorAdminId + } + return 0 +} + +type SetLuckyGiftExperimentOverridesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Experiment *LuckyGiftExperiment `protobuf:"bytes,1,opt,name=experiment,proto3" json:"experiment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetLuckyGiftExperimentOverridesResponse) Reset() { + *x = SetLuckyGiftExperimentOverridesResponse{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetLuckyGiftExperimentOverridesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetLuckyGiftExperimentOverridesResponse) ProtoMessage() {} + +func (x *SetLuckyGiftExperimentOverridesResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetLuckyGiftExperimentOverridesResponse.ProtoReflect.Descriptor instead. +func (*SetLuckyGiftExperimentOverridesResponse) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{38} +} + +func (x *SetLuckyGiftExperimentOverridesResponse) GetExperiment() *LuckyGiftExperiment { + if x != nil { + return x.Experiment + } + return nil +} + +// EndLuckyGiftExperimentRequest 结束实验:把 winner_arm 钉住的配置快照重发布为新的最新版本, +// 同事务内完成实验收口,保证结束瞬间的版本收敛没有中间态。 +type EndLuckyGiftExperimentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + ExperimentId string `protobuf:"bytes,2,opt,name=experiment_id,json=experimentId,proto3" json:"experiment_id,omitempty"` + WinnerArm string `protobuf:"bytes,3,opt,name=winner_arm,json=winnerArm,proto3" json:"winner_arm,omitempty"` + OperatorAdminId int64 `protobuf:"varint,4,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EndLuckyGiftExperimentRequest) Reset() { + *x = EndLuckyGiftExperimentRequest{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EndLuckyGiftExperimentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EndLuckyGiftExperimentRequest) ProtoMessage() {} + +func (x *EndLuckyGiftExperimentRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EndLuckyGiftExperimentRequest.ProtoReflect.Descriptor instead. +func (*EndLuckyGiftExperimentRequest) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{39} +} + +func (x *EndLuckyGiftExperimentRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *EndLuckyGiftExperimentRequest) GetExperimentId() string { + if x != nil { + return x.ExperimentId + } + return "" +} + +func (x *EndLuckyGiftExperimentRequest) GetWinnerArm() string { + if x != nil { + return x.WinnerArm + } + return "" +} + +func (x *EndLuckyGiftExperimentRequest) GetOperatorAdminId() int64 { + if x != nil { + return x.OperatorAdminId + } + return 0 +} + +type EndLuckyGiftExperimentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Experiment *LuckyGiftExperiment `protobuf:"bytes,1,opt,name=experiment,proto3" json:"experiment,omitempty"` + FinalConfig *LuckyGiftRuleConfig `protobuf:"bytes,2,opt,name=final_config,json=finalConfig,proto3" json:"final_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EndLuckyGiftExperimentResponse) Reset() { + *x = EndLuckyGiftExperimentResponse{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EndLuckyGiftExperimentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EndLuckyGiftExperimentResponse) ProtoMessage() {} + +func (x *EndLuckyGiftExperimentResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EndLuckyGiftExperimentResponse.ProtoReflect.Descriptor instead. +func (*EndLuckyGiftExperimentResponse) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{40} +} + +func (x *EndLuckyGiftExperimentResponse) GetExperiment() *LuckyGiftExperiment { + if x != nil { + return x.Experiment + } + return nil +} + +func (x *EndLuckyGiftExperimentResponse) GetFinalConfig() *LuckyGiftRuleConfig { + if x != nil { + return x.FinalConfig + } + return nil +} + // LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。 // 声明追加在文件尾以保持既有 protobuf message index 稳定。 type LuckyGiftHit struct { @@ -2641,7 +3597,7 @@ type LuckyGiftHit struct { func (x *LuckyGiftHit) Reset() { *x = LuckyGiftHit{} - mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[28] + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2653,7 +3609,7 @@ func (x *LuckyGiftHit) String() string { func (*LuckyGiftHit) ProtoMessage() {} func (x *LuckyGiftHit) ProtoReflect() protoreflect.Message { - mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[28] + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2666,7 +3622,7 @@ func (x *LuckyGiftHit) ProtoReflect() protoreflect.Message { // Deprecated: Use LuckyGiftHit.ProtoReflect.Descriptor instead. func (*LuckyGiftHit) Descriptor() ([]byte, []int) { - return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{28} + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{41} } func (x *LuckyGiftHit) GetGiftIndex() int32 { @@ -2722,7 +3678,7 @@ type AdjustLuckyGiftPoolBalanceRequest struct { func (x *AdjustLuckyGiftPoolBalanceRequest) Reset() { *x = AdjustLuckyGiftPoolBalanceRequest{} - mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[29] + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2734,7 +3690,7 @@ func (x *AdjustLuckyGiftPoolBalanceRequest) String() string { func (*AdjustLuckyGiftPoolBalanceRequest) ProtoMessage() {} func (x *AdjustLuckyGiftPoolBalanceRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[29] + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2747,7 +3703,7 @@ func (x *AdjustLuckyGiftPoolBalanceRequest) ProtoReflect() protoreflect.Message // Deprecated: Use AdjustLuckyGiftPoolBalanceRequest.ProtoReflect.Descriptor instead. func (*AdjustLuckyGiftPoolBalanceRequest) Descriptor() ([]byte, []int) { - return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{29} + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{42} } func (x *AdjustLuckyGiftPoolBalanceRequest) GetMeta() *RequestMeta { @@ -2825,7 +3781,7 @@ type LuckyGiftPoolAdjustment struct { func (x *LuckyGiftPoolAdjustment) Reset() { *x = LuckyGiftPoolAdjustment{} - mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[30] + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2837,7 +3793,7 @@ func (x *LuckyGiftPoolAdjustment) String() string { func (*LuckyGiftPoolAdjustment) ProtoMessage() {} func (x *LuckyGiftPoolAdjustment) ProtoReflect() protoreflect.Message { - mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[30] + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2850,7 +3806,7 @@ func (x *LuckyGiftPoolAdjustment) ProtoReflect() protoreflect.Message { // Deprecated: Use LuckyGiftPoolAdjustment.ProtoReflect.Descriptor instead. func (*LuckyGiftPoolAdjustment) Descriptor() ([]byte, []int) { - return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{30} + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{43} } func (x *LuckyGiftPoolAdjustment) GetAdjustmentId() string { @@ -2941,7 +3897,7 @@ type AdjustLuckyGiftPoolBalanceResponse struct { func (x *AdjustLuckyGiftPoolBalanceResponse) Reset() { *x = AdjustLuckyGiftPoolBalanceResponse{} - mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[31] + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2953,7 +3909,7 @@ func (x *AdjustLuckyGiftPoolBalanceResponse) String() string { func (*AdjustLuckyGiftPoolBalanceResponse) ProtoMessage() {} func (x *AdjustLuckyGiftPoolBalanceResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[31] + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2966,7 +3922,7 @@ func (x *AdjustLuckyGiftPoolBalanceResponse) ProtoReflect() protoreflect.Message // Deprecated: Use AdjustLuckyGiftPoolBalanceResponse.ProtoReflect.Descriptor instead. func (*AdjustLuckyGiftPoolBalanceResponse) Descriptor() ([]byte, []int) { - return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{31} + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{44} } func (x *AdjustLuckyGiftPoolBalanceResponse) GetAdjustment() *LuckyGiftPoolAdjustment { @@ -3248,7 +4204,94 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12)\n" + "\x10strategy_version\x18\x03 \x01(\tR\x0fstrategyVersion\"c\n" + "!ListLuckyGiftPoolBalancesResponse\x12>\n" + - "\x05pools\x18\x01 \x03(\v2(.hyapp.luckygift.v1.LuckyGiftPoolBalanceR\x05pools\"\xba\x01\n" + + "\x05pools\x18\x01 \x03(\v2(.hyapp.luckygift.v1.LuckyGiftPoolBalanceR\x05pools\"J\n" + + "\x1bLuckyGiftExperimentOverride\x12\x19\n" + + "\buser_key\x18\x01 \x01(\tR\auserKey\x12\x10\n" + + "\x03arm\x18\x02 \x01(\tR\x03arm\"\xc1\x02\n" + + "\x1bLuckyGiftExperimentArmStats\x12\x10\n" + + "\x03arm\x18\x01 \x01(\tR\x03arm\x12!\n" + + "\frule_version\x18\x02 \x01(\x03R\vruleVersion\x12)\n" + + "\x10strategy_version\x18\x03 \x01(\tR\x0fstrategyVersion\x12\x1f\n" + + "\vtotal_draws\x18\x04 \x01(\x03R\n" + + "totalDraws\x12!\n" + + "\funique_users\x18\x05 \x01(\x03R\vuniqueUsers\x12*\n" + + "\x11total_spent_coins\x18\x06 \x01(\x03R\x0ftotalSpentCoins\x12,\n" + + "\x12total_reward_coins\x18\a \x01(\x03R\x10totalRewardCoins\x12$\n" + + "\x0eactual_rtp_ppm\x18\b \x01(\x03R\factualRtpPpm\"\x88\x06\n" + + "\x13LuckyGiftExperiment\x12\x19\n" + + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12#\n" + + "\rexperiment_id\x18\x03 \x01(\tR\fexperimentId\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x120\n" + + "\x14control_rule_version\x18\x05 \x01(\x03R\x12controlRuleVersion\x124\n" + + "\x16treatment_rule_version\x18\x06 \x01(\x03R\x14treatmentRuleVersion\x122\n" + + "\x15treatment_traffic_ppm\x18\a \x01(\x03R\x13treatmentTrafficPpm\x12\x1f\n" + + "\vbucket_salt\x18\b \x01(\tR\n" + + "bucketSalt\x12\x16\n" + + "\x06status\x18\t \x01(\tR\x06status\x12\x1d\n" + + "\n" + + "winner_arm\x18\n" + + " \x01(\tR\twinnerArm\x12.\n" + + "\x13winner_rule_version\x18\v \x01(\x03R\x11winnerRuleVersion\x12,\n" + + "\x12final_rule_version\x18\f \x01(\x03R\x10finalRuleVersion\x12-\n" + + "\x13created_by_admin_id\x18\r \x01(\x03R\x10createdByAdminId\x12\"\n" + + "\rstarted_at_ms\x18\x0e \x01(\x03R\vstartedAtMs\x12\x1e\n" + + "\vended_at_ms\x18\x0f \x01(\x03R\tendedAtMs\x12\"\n" + + "\rupdated_at_ms\x18\x10 \x01(\x03R\vupdatedAtMs\x12M\n" + + "\toverrides\x18\x11 \x03(\v2/.hyapp.luckygift.v1.LuckyGiftExperimentOverrideR\toverrides\x12L\n" + + "\tarm_stats\x18\x12 \x03(\v2/.hyapp.luckygift.v1.LuckyGiftExperimentArmStatsR\barmStats\"\xee\x02\n" + + " CreateLuckyGiftExperimentRequest\x123\n" + + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12R\n" + + "\x10treatment_config\x18\x03 \x01(\v2'.hyapp.luckygift.v1.LuckyGiftRuleConfigR\x0ftreatmentConfig\x122\n" + + "\x15treatment_traffic_ppm\x18\x04 \x01(\x03R\x13treatmentTrafficPpm\x12*\n" + + "\x11operator_admin_id\x18\x05 \x01(\x03R\x0foperatorAdminId\x12M\n" + + "\toverrides\x18\x06 \x03(\v2/.hyapp.luckygift.v1.LuckyGiftExperimentOverrideR\toverrides\"\xc0\x01\n" + + "!CreateLuckyGiftExperimentResponse\x12G\n" + + "\n" + + "experiment\x18\x01 \x01(\v2'.hyapp.luckygift.v1.LuckyGiftExperimentR\n" + + "experiment\x12R\n" + + "\x10treatment_config\x18\x02 \x01(\v2'.hyapp.luckygift.v1.LuckyGiftRuleConfigR\x0ftreatmentConfig\"\xa6\x01\n" + + "\x1fListLuckyGiftExperimentsRequest\x123\n" + + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x1d\n" + + "\n" + + "with_stats\x18\x04 \x01(\bR\twithStats\"m\n" + + " ListLuckyGiftExperimentsResponse\x12I\n" + + "\vexperiments\x18\x01 \x03(\v2'.hyapp.luckygift.v1.LuckyGiftExperimentR\vexperiments\"\x93\x02\n" + + " UpdateLuckyGiftExperimentRequest\x123\n" + + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12#\n" + + "\rexperiment_id\x18\x02 \x01(\tR\fexperimentId\x127\n" + + "\x15treatment_traffic_ppm\x18\x03 \x01(\x03H\x00R\x13treatmentTrafficPpm\x88\x01\x01\x12\x16\n" + + "\x06status\x18\x04 \x01(\tR\x06status\x12*\n" + + "\x11operator_admin_id\x18\x05 \x01(\x03R\x0foperatorAdminIdB\x18\n" + + "\x16_treatment_traffic_ppm\"l\n" + + "!UpdateLuckyGiftExperimentResponse\x12G\n" + + "\n" + + "experiment\x18\x01 \x01(\v2'.hyapp.luckygift.v1.LuckyGiftExperimentR\n" + + "experiment\"\x93\x02\n" + + "&SetLuckyGiftExperimentOverridesRequest\x123\n" + + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12#\n" + + "\rexperiment_id\x18\x02 \x01(\tR\fexperimentId\x12I\n" + + "\aupserts\x18\x03 \x03(\v2/.hyapp.luckygift.v1.LuckyGiftExperimentOverrideR\aupserts\x12\x18\n" + + "\aremoves\x18\x04 \x03(\tR\aremoves\x12*\n" + + "\x11operator_admin_id\x18\x05 \x01(\x03R\x0foperatorAdminId\"r\n" + + "'SetLuckyGiftExperimentOverridesResponse\x12G\n" + + "\n" + + "experiment\x18\x01 \x01(\v2'.hyapp.luckygift.v1.LuckyGiftExperimentR\n" + + "experiment\"\xc4\x01\n" + + "\x1dEndLuckyGiftExperimentRequest\x123\n" + + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12#\n" + + "\rexperiment_id\x18\x02 \x01(\tR\fexperimentId\x12\x1d\n" + + "\n" + + "winner_arm\x18\x03 \x01(\tR\twinnerArm\x12*\n" + + "\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\"\xb5\x01\n" + + "\x1eEndLuckyGiftExperimentResponse\x12G\n" + + "\n" + + "experiment\x18\x01 \x01(\v2'.hyapp.luckygift.v1.LuckyGiftExperimentR\n" + + "experiment\x12J\n" + + "\ffinal_config\x18\x02 \x01(\v2'.hyapp.luckygift.v1.LuckyGiftRuleConfigR\vfinalConfig\"\xba\x01\n" + "\fLuckyGiftHit\x12\x1d\n" + "\n" + "gift_index\x18\x01 \x01(\x05R\tgiftIndex\x12\x17\n" + @@ -3288,7 +4331,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x0eCheckLuckyGift\x12).hyapp.luckygift.v1.CheckLuckyGiftRequest\x1a*.hyapp.luckygift.v1.CheckLuckyGiftResponse\x12y\n" + "\x14ExecuteLuckyGiftDraw\x12/.hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest\x1a0.hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse\x12\x88\x01\n" + "\x19BatchExecuteLuckyGiftDraw\x124.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest\x1a5.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse\x12t\n" + - "\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\x98\a\n" + + "\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\xd4\f\n" + "\x15AdminLuckyGiftService\x12s\n" + "\x12GetLuckyGiftConfig\x12-.hyapp.luckygift.v1.GetLuckyGiftConfigRequest\x1a..hyapp.luckygift.v1.GetLuckyGiftConfigResponse\x12|\n" + "\x15UpsertLuckyGiftConfig\x120.hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest\x1a1.hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse\x12y\n" + @@ -3296,7 +4339,12 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x12ListLuckyGiftDraws\x12-.hyapp.luckygift.v1.ListLuckyGiftDrawsRequest\x1a..hyapp.luckygift.v1.ListLuckyGiftDrawsResponse\x12\x82\x01\n" + "\x17GetLuckyGiftDrawSummary\x122.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest\x1a3.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse\x12\x88\x01\n" + "\x19ListLuckyGiftPoolBalances\x124.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest\x1a5.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse\x12\x8b\x01\n" + - "\x1aAdjustLuckyGiftPoolBalance\x125.hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest\x1a6.hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3" + "\x1aAdjustLuckyGiftPoolBalance\x125.hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest\x1a6.hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse\x12\x88\x01\n" + + "\x19CreateLuckyGiftExperiment\x124.hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest\x1a5.hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse\x12\x85\x01\n" + + "\x18ListLuckyGiftExperiments\x123.hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest\x1a4.hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse\x12\x88\x01\n" + + "\x19UpdateLuckyGiftExperiment\x124.hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest\x1a5.hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse\x12\x9a\x01\n" + + "\x1fSetLuckyGiftExperimentOverrides\x12:.hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest\x1a;.hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse\x12\x7f\n" + + "\x16EndLuckyGiftExperiment\x121.hyapp.luckygift.v1.EndLuckyGiftExperimentRequest\x1a2.hyapp.luckygift.v1.EndLuckyGiftExperimentResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3" var ( file_proto_luckygift_v1_luckygift_proto_rawDescOnce sync.Once @@ -3310,47 +4358,60 @@ func file_proto_luckygift_v1_luckygift_proto_rawDescGZIP() []byte { return file_proto_luckygift_v1_luckygift_proto_rawDescData } -var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 45) var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{ - (*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta - (*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta - (*LuckyGiftRuleTier)(nil), // 2: hyapp.luckygift.v1.LuckyGiftRuleTier - (*LuckyGiftRuleStage)(nil), // 3: hyapp.luckygift.v1.LuckyGiftRuleStage - (*LuckyGiftRuleConfig)(nil), // 4: hyapp.luckygift.v1.LuckyGiftRuleConfig - (*CheckLuckyGiftRequest)(nil), // 5: hyapp.luckygift.v1.CheckLuckyGiftRequest - (*CheckLuckyGiftResponse)(nil), // 6: hyapp.luckygift.v1.CheckLuckyGiftResponse - (*LuckyGiftDrawResult)(nil), // 7: hyapp.luckygift.v1.LuckyGiftDrawResult - (*ExecuteLuckyGiftDrawRequest)(nil), // 8: hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest - (*ExecuteLuckyGiftDrawResponse)(nil), // 9: hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse - (*BatchExecuteLuckyGiftDrawRequest)(nil), // 10: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest - (*BatchExecuteLuckyGiftDrawResponse)(nil), // 11: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse - (*ExternalGiftDrawRequest)(nil), // 12: hyapp.luckygift.v1.ExternalGiftDrawRequest - (*ExternalGiftDrawResponse)(nil), // 13: hyapp.luckygift.v1.ExternalGiftDrawResponse - (*GetLuckyGiftConfigRequest)(nil), // 14: hyapp.luckygift.v1.GetLuckyGiftConfigRequest - (*GetLuckyGiftConfigResponse)(nil), // 15: hyapp.luckygift.v1.GetLuckyGiftConfigResponse - (*UpsertLuckyGiftConfigRequest)(nil), // 16: hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest - (*UpsertLuckyGiftConfigResponse)(nil), // 17: hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse - (*ListLuckyGiftConfigsRequest)(nil), // 18: hyapp.luckygift.v1.ListLuckyGiftConfigsRequest - (*ListLuckyGiftConfigsResponse)(nil), // 19: hyapp.luckygift.v1.ListLuckyGiftConfigsResponse - (*ListLuckyGiftDrawsRequest)(nil), // 20: hyapp.luckygift.v1.ListLuckyGiftDrawsRequest - (*ListLuckyGiftDrawsResponse)(nil), // 21: hyapp.luckygift.v1.ListLuckyGiftDrawsResponse - (*LuckyGiftDrawSummary)(nil), // 22: hyapp.luckygift.v1.LuckyGiftDrawSummary - (*LuckyGiftPoolBalance)(nil), // 23: hyapp.luckygift.v1.LuckyGiftPoolBalance - (*GetLuckyGiftDrawSummaryRequest)(nil), // 24: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest - (*GetLuckyGiftDrawSummaryResponse)(nil), // 25: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse - (*ListLuckyGiftPoolBalancesRequest)(nil), // 26: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest - (*ListLuckyGiftPoolBalancesResponse)(nil), // 27: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse - (*LuckyGiftHit)(nil), // 28: hyapp.luckygift.v1.LuckyGiftHit - (*AdjustLuckyGiftPoolBalanceRequest)(nil), // 29: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest - (*LuckyGiftPoolAdjustment)(nil), // 30: hyapp.luckygift.v1.LuckyGiftPoolAdjustment - (*AdjustLuckyGiftPoolBalanceResponse)(nil), // 31: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse + (*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta + (*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta + (*LuckyGiftRuleTier)(nil), // 2: hyapp.luckygift.v1.LuckyGiftRuleTier + (*LuckyGiftRuleStage)(nil), // 3: hyapp.luckygift.v1.LuckyGiftRuleStage + (*LuckyGiftRuleConfig)(nil), // 4: hyapp.luckygift.v1.LuckyGiftRuleConfig + (*CheckLuckyGiftRequest)(nil), // 5: hyapp.luckygift.v1.CheckLuckyGiftRequest + (*CheckLuckyGiftResponse)(nil), // 6: hyapp.luckygift.v1.CheckLuckyGiftResponse + (*LuckyGiftDrawResult)(nil), // 7: hyapp.luckygift.v1.LuckyGiftDrawResult + (*ExecuteLuckyGiftDrawRequest)(nil), // 8: hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest + (*ExecuteLuckyGiftDrawResponse)(nil), // 9: hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse + (*BatchExecuteLuckyGiftDrawRequest)(nil), // 10: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest + (*BatchExecuteLuckyGiftDrawResponse)(nil), // 11: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse + (*ExternalGiftDrawRequest)(nil), // 12: hyapp.luckygift.v1.ExternalGiftDrawRequest + (*ExternalGiftDrawResponse)(nil), // 13: hyapp.luckygift.v1.ExternalGiftDrawResponse + (*GetLuckyGiftConfigRequest)(nil), // 14: hyapp.luckygift.v1.GetLuckyGiftConfigRequest + (*GetLuckyGiftConfigResponse)(nil), // 15: hyapp.luckygift.v1.GetLuckyGiftConfigResponse + (*UpsertLuckyGiftConfigRequest)(nil), // 16: hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest + (*UpsertLuckyGiftConfigResponse)(nil), // 17: hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse + (*ListLuckyGiftConfigsRequest)(nil), // 18: hyapp.luckygift.v1.ListLuckyGiftConfigsRequest + (*ListLuckyGiftConfigsResponse)(nil), // 19: hyapp.luckygift.v1.ListLuckyGiftConfigsResponse + (*ListLuckyGiftDrawsRequest)(nil), // 20: hyapp.luckygift.v1.ListLuckyGiftDrawsRequest + (*ListLuckyGiftDrawsResponse)(nil), // 21: hyapp.luckygift.v1.ListLuckyGiftDrawsResponse + (*LuckyGiftDrawSummary)(nil), // 22: hyapp.luckygift.v1.LuckyGiftDrawSummary + (*LuckyGiftPoolBalance)(nil), // 23: hyapp.luckygift.v1.LuckyGiftPoolBalance + (*GetLuckyGiftDrawSummaryRequest)(nil), // 24: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest + (*GetLuckyGiftDrawSummaryResponse)(nil), // 25: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse + (*ListLuckyGiftPoolBalancesRequest)(nil), // 26: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest + (*ListLuckyGiftPoolBalancesResponse)(nil), // 27: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse + (*LuckyGiftExperimentOverride)(nil), // 28: hyapp.luckygift.v1.LuckyGiftExperimentOverride + (*LuckyGiftExperimentArmStats)(nil), // 29: hyapp.luckygift.v1.LuckyGiftExperimentArmStats + (*LuckyGiftExperiment)(nil), // 30: hyapp.luckygift.v1.LuckyGiftExperiment + (*CreateLuckyGiftExperimentRequest)(nil), // 31: hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest + (*CreateLuckyGiftExperimentResponse)(nil), // 32: hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse + (*ListLuckyGiftExperimentsRequest)(nil), // 33: hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest + (*ListLuckyGiftExperimentsResponse)(nil), // 34: hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse + (*UpdateLuckyGiftExperimentRequest)(nil), // 35: hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest + (*UpdateLuckyGiftExperimentResponse)(nil), // 36: hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse + (*SetLuckyGiftExperimentOverridesRequest)(nil), // 37: hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest + (*SetLuckyGiftExperimentOverridesResponse)(nil), // 38: hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse + (*EndLuckyGiftExperimentRequest)(nil), // 39: hyapp.luckygift.v1.EndLuckyGiftExperimentRequest + (*EndLuckyGiftExperimentResponse)(nil), // 40: hyapp.luckygift.v1.EndLuckyGiftExperimentResponse + (*LuckyGiftHit)(nil), // 41: hyapp.luckygift.v1.LuckyGiftHit + (*AdjustLuckyGiftPoolBalanceRequest)(nil), // 42: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest + (*LuckyGiftPoolAdjustment)(nil), // 43: hyapp.luckygift.v1.LuckyGiftPoolAdjustment + (*AdjustLuckyGiftPoolBalanceResponse)(nil), // 44: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse } var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{ 0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta 2, // 1: hyapp.luckygift.v1.LuckyGiftRuleStage.tiers:type_name -> hyapp.luckygift.v1.LuckyGiftRuleTier 3, // 2: hyapp.luckygift.v1.LuckyGiftRuleConfig.stages:type_name -> hyapp.luckygift.v1.LuckyGiftRuleStage 0, // 3: hyapp.luckygift.v1.CheckLuckyGiftRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta - 28, // 4: hyapp.luckygift.v1.LuckyGiftDrawResult.hits:type_name -> hyapp.luckygift.v1.LuckyGiftHit + 41, // 4: hyapp.luckygift.v1.LuckyGiftDrawResult.hits:type_name -> hyapp.luckygift.v1.LuckyGiftHit 1, // 5: hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest.lucky_gift:type_name -> hyapp.luckygift.v1.LuckyGiftMeta 7, // 6: hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse.result:type_name -> hyapp.luckygift.v1.LuckyGiftDrawResult 1, // 7: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest.lucky_gifts:type_name -> hyapp.luckygift.v1.LuckyGiftMeta @@ -3369,36 +4430,63 @@ var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{ 22, // 20: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.luckygift.v1.LuckyGiftDrawSummary 0, // 21: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta 23, // 22: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse.pools:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance - 0, // 23: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta - 30, // 24: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.adjustment:type_name -> hyapp.luckygift.v1.LuckyGiftPoolAdjustment - 23, // 25: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.pool:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance - 5, // 26: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest - 8, // 27: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest - 10, // 28: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest - 12, // 29: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest - 14, // 30: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest - 16, // 31: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest - 18, // 32: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest - 20, // 33: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest - 24, // 34: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest - 26, // 35: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest - 29, // 36: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest - 6, // 37: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse - 9, // 38: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse - 11, // 39: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse - 13, // 40: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse - 15, // 41: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse - 17, // 42: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse - 19, // 43: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse - 21, // 44: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse - 25, // 45: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse - 27, // 46: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse - 31, // 47: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse - 37, // [37:48] is the sub-list for method output_type - 26, // [26:37] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 28, // 23: hyapp.luckygift.v1.LuckyGiftExperiment.overrides:type_name -> hyapp.luckygift.v1.LuckyGiftExperimentOverride + 29, // 24: hyapp.luckygift.v1.LuckyGiftExperiment.arm_stats:type_name -> hyapp.luckygift.v1.LuckyGiftExperimentArmStats + 0, // 25: hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 4, // 26: hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest.treatment_config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig + 28, // 27: hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest.overrides:type_name -> hyapp.luckygift.v1.LuckyGiftExperimentOverride + 30, // 28: hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse.experiment:type_name -> hyapp.luckygift.v1.LuckyGiftExperiment + 4, // 29: hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse.treatment_config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig + 0, // 30: hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 30, // 31: hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse.experiments:type_name -> hyapp.luckygift.v1.LuckyGiftExperiment + 0, // 32: hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 30, // 33: hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse.experiment:type_name -> hyapp.luckygift.v1.LuckyGiftExperiment + 0, // 34: hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 28, // 35: hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest.upserts:type_name -> hyapp.luckygift.v1.LuckyGiftExperimentOverride + 30, // 36: hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse.experiment:type_name -> hyapp.luckygift.v1.LuckyGiftExperiment + 0, // 37: hyapp.luckygift.v1.EndLuckyGiftExperimentRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 30, // 38: hyapp.luckygift.v1.EndLuckyGiftExperimentResponse.experiment:type_name -> hyapp.luckygift.v1.LuckyGiftExperiment + 4, // 39: hyapp.luckygift.v1.EndLuckyGiftExperimentResponse.final_config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig + 0, // 40: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 43, // 41: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.adjustment:type_name -> hyapp.luckygift.v1.LuckyGiftPoolAdjustment + 23, // 42: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.pool:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance + 5, // 43: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest + 8, // 44: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest + 10, // 45: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest + 12, // 46: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest + 14, // 47: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest + 16, // 48: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest + 18, // 49: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest + 20, // 50: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest + 24, // 51: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest + 26, // 52: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest + 42, // 53: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest + 31, // 54: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentRequest + 33, // 55: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:input_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsRequest + 35, // 56: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentRequest + 37, // 57: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:input_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesRequest + 39, // 58: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:input_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentRequest + 6, // 59: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse + 9, // 60: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse + 11, // 61: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse + 13, // 62: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse + 15, // 63: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse + 17, // 64: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse + 19, // 65: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse + 21, // 66: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse + 25, // 67: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse + 27, // 68: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse + 44, // 69: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse + 32, // 70: hyapp.luckygift.v1.AdminLuckyGiftService.CreateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.CreateLuckyGiftExperimentResponse + 34, // 71: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftExperiments:output_type -> hyapp.luckygift.v1.ListLuckyGiftExperimentsResponse + 36, // 72: hyapp.luckygift.v1.AdminLuckyGiftService.UpdateLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.UpdateLuckyGiftExperimentResponse + 38, // 73: hyapp.luckygift.v1.AdminLuckyGiftService.SetLuckyGiftExperimentOverrides:output_type -> hyapp.luckygift.v1.SetLuckyGiftExperimentOverridesResponse + 40, // 74: hyapp.luckygift.v1.AdminLuckyGiftService.EndLuckyGiftExperiment:output_type -> hyapp.luckygift.v1.EndLuckyGiftExperimentResponse + 59, // [59:75] is the sub-list for method output_type + 43, // [43:59] is the sub-list for method input_type + 43, // [43:43] is the sub-list for extension type_name + 43, // [43:43] is the sub-list for extension extendee + 0, // [0:43] is the sub-list for field type_name } func init() { file_proto_luckygift_v1_luckygift_proto_init() } @@ -3406,13 +4494,14 @@ func file_proto_luckygift_v1_luckygift_proto_init() { if File_proto_luckygift_v1_luckygift_proto != nil { return } + file_proto_luckygift_v1_luckygift_proto_msgTypes[35].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_luckygift_v1_luckygift_proto_rawDesc), len(file_proto_luckygift_v1_luckygift_proto_rawDesc)), NumEnums: 0, - NumMessages: 32, + NumMessages: 45, NumExtensions: 0, NumServices: 2, }, diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index 1c2df5fe..76a835b6 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -299,6 +299,120 @@ message ListLuckyGiftPoolBalancesResponse { repeated LuckyGiftPoolBalance pools = 1; } +// LuckyGiftExperimentOverride 把单个用户强制固定到某个实验组,用于放量前的白名单验证。 +// user_key:内部用户为十进制 user_id 字符串;外部用户为 "ext:" + external_user_id。 +message LuckyGiftExperimentOverride { + string user_key = 1; + string arm = 2; +} + +// LuckyGiftExperimentArmStats 是实验开始以来单组的抽奖聚合,由 lucky_draw_records 按 rule_version 现算,非物化口径。 +message LuckyGiftExperimentArmStats { + string arm = 1; + int64 rule_version = 2; + string strategy_version = 3; + int64 total_draws = 4; + int64 unique_users = 5; + int64 total_spent_coins = 6; + int64 total_reward_coins = 7; + int64 actual_rtp_ppm = 8; +} + +// LuckyGiftExperiment 是单奖池 AB 实验事实:两组各钉住一个不可变 rule_version, +// 运行时按 bucket_salt+user 哈希分流;同一奖池同时最多一个未完成实验。 +message LuckyGiftExperiment { + string app_code = 1; + string pool_id = 2; + string experiment_id = 3; + string name = 4; + int64 control_rule_version = 5; + int64 treatment_rule_version = 6; + // treatment 组流量比例(ppm,0..1000000);0 表示只有白名单 override 能进入 treatment。 + int64 treatment_traffic_ppm = 7; + string bucket_salt = 8; + // running / paused / completed;paused 时全部流量(含白名单)回落 control。 + string status = 9; + string winner_arm = 10; + int64 winner_rule_version = 11; + // 结束实验时把获胜配置重发布成的新最新版本,全量流量由它承接。 + int64 final_rule_version = 12; + int64 created_by_admin_id = 13; + int64 started_at_ms = 14; + int64 ended_at_ms = 15; + int64 updated_at_ms = 16; + repeated LuckyGiftExperimentOverride overrides = 17; + repeated LuckyGiftExperimentArmStats arm_stats = 18; +} + +// CreateLuckyGiftExperimentRequest 在一个事务里发布 treatment 新版本并登记实验, +// 避免"先发布后建实验"窗口期内新版本被最新版本语义全量放开。 +// control 组自动钉住当前最新已启用版本。 +message CreateLuckyGiftExperimentRequest { + RequestMeta meta = 1; + string name = 2; + LuckyGiftRuleConfig treatment_config = 3; + int64 treatment_traffic_ppm = 4; + int64 operator_admin_id = 5; + repeated LuckyGiftExperimentOverride overrides = 6; +} + +message CreateLuckyGiftExperimentResponse { + LuckyGiftExperiment experiment = 1; + LuckyGiftRuleConfig treatment_config = 2; +} + +message ListLuckyGiftExperimentsRequest { + RequestMeta meta = 1; + string pool_id = 2; + string status = 3; + bool with_stats = 4; +} + +message ListLuckyGiftExperimentsResponse { + repeated LuckyGiftExperiment experiments = 1; +} + +// UpdateLuckyGiftExperimentRequest 调整放量或暂停/恢复。 +// treatment_traffic_ppm 使用显式存在性语义:未设置表示不修改流量,避免 proto3 缺省 0 被误解释成"放量清零"; +// status 为空表示不修改状态。 +message UpdateLuckyGiftExperimentRequest { + RequestMeta meta = 1; + string experiment_id = 2; + optional int64 treatment_traffic_ppm = 3; + string status = 4; + int64 operator_admin_id = 5; +} + +message UpdateLuckyGiftExperimentResponse { + LuckyGiftExperiment experiment = 1; +} + +message SetLuckyGiftExperimentOverridesRequest { + RequestMeta meta = 1; + string experiment_id = 2; + repeated LuckyGiftExperimentOverride upserts = 3; + repeated string removes = 4; + int64 operator_admin_id = 5; +} + +message SetLuckyGiftExperimentOverridesResponse { + LuckyGiftExperiment experiment = 1; +} + +// EndLuckyGiftExperimentRequest 结束实验:把 winner_arm 钉住的配置快照重发布为新的最新版本, +// 同事务内完成实验收口,保证结束瞬间的版本收敛没有中间态。 +message EndLuckyGiftExperimentRequest { + RequestMeta meta = 1; + string experiment_id = 2; + string winner_arm = 3; + int64 operator_admin_id = 4; +} + +message EndLuckyGiftExperimentResponse { + LuckyGiftExperiment experiment = 1; + LuckyGiftRuleConfig final_config = 2; +} + service LuckyGiftService { rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse); rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse); @@ -314,6 +428,11 @@ service AdminLuckyGiftService { rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse); rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse); rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse); + rpc CreateLuckyGiftExperiment(CreateLuckyGiftExperimentRequest) returns (CreateLuckyGiftExperimentResponse); + rpc ListLuckyGiftExperiments(ListLuckyGiftExperimentsRequest) returns (ListLuckyGiftExperimentsResponse); + rpc UpdateLuckyGiftExperiment(UpdateLuckyGiftExperimentRequest) returns (UpdateLuckyGiftExperimentResponse); + rpc SetLuckyGiftExperimentOverrides(SetLuckyGiftExperimentOverridesRequest) returns (SetLuckyGiftExperimentOverridesResponse); + rpc EndLuckyGiftExperiment(EndLuckyGiftExperimentRequest) returns (EndLuckyGiftExperimentResponse); } // LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。 diff --git a/api/proto/luckygift/v1/luckygift_grpc.pb.go b/api/proto/luckygift/v1/luckygift_grpc.pb.go index 4c4ce555..2f207f08 100644 --- a/api/proto/luckygift/v1/luckygift_grpc.pb.go +++ b/api/proto/luckygift/v1/luckygift_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/luckygift/v1/luckygift.proto package luckygiftv1 @@ -235,13 +235,18 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{ } const ( - AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig" - AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig" - AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs" - AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws" - AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary" - AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances" - AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance" + AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig" + AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig" + AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs" + AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws" + AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary" + AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances" + AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance" + AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/CreateLuckyGiftExperiment" + AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftExperiments" + AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpdateLuckyGiftExperiment" + AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/SetLuckyGiftExperimentOverrides" + AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/EndLuckyGiftExperiment" ) // AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service. @@ -255,6 +260,11 @@ type AdminLuckyGiftServiceClient interface { GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error) AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, error) + CreateLuckyGiftExperiment(ctx context.Context, in *CreateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*CreateLuckyGiftExperimentResponse, error) + ListLuckyGiftExperiments(ctx context.Context, in *ListLuckyGiftExperimentsRequest, opts ...grpc.CallOption) (*ListLuckyGiftExperimentsResponse, error) + UpdateLuckyGiftExperiment(ctx context.Context, in *UpdateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*UpdateLuckyGiftExperimentResponse, error) + SetLuckyGiftExperimentOverrides(ctx context.Context, in *SetLuckyGiftExperimentOverridesRequest, opts ...grpc.CallOption) (*SetLuckyGiftExperimentOverridesResponse, error) + EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error) } type adminLuckyGiftServiceClient struct { @@ -335,6 +345,56 @@ func (c *adminLuckyGiftServiceClient) AdjustLuckyGiftPoolBalance(ctx context.Con return out, nil } +func (c *adminLuckyGiftServiceClient) CreateLuckyGiftExperiment(ctx context.Context, in *CreateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*CreateLuckyGiftExperimentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateLuckyGiftExperimentResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *adminLuckyGiftServiceClient) ListLuckyGiftExperiments(ctx context.Context, in *ListLuckyGiftExperimentsRequest, opts ...grpc.CallOption) (*ListLuckyGiftExperimentsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListLuckyGiftExperimentsResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *adminLuckyGiftServiceClient) UpdateLuckyGiftExperiment(ctx context.Context, in *UpdateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*UpdateLuckyGiftExperimentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateLuckyGiftExperimentResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *adminLuckyGiftServiceClient) SetLuckyGiftExperimentOverrides(ctx context.Context, in *SetLuckyGiftExperimentOverridesRequest, opts ...grpc.CallOption) (*SetLuckyGiftExperimentOverridesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetLuckyGiftExperimentOverridesResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *adminLuckyGiftServiceClient) EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EndLuckyGiftExperimentResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service. // All implementations must embed UnimplementedAdminLuckyGiftServiceServer // for forward compatibility. @@ -346,6 +406,11 @@ type AdminLuckyGiftServiceServer interface { GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) + CreateLuckyGiftExperiment(context.Context, *CreateLuckyGiftExperimentRequest) (*CreateLuckyGiftExperimentResponse, error) + ListLuckyGiftExperiments(context.Context, *ListLuckyGiftExperimentsRequest) (*ListLuckyGiftExperimentsResponse, error) + UpdateLuckyGiftExperiment(context.Context, *UpdateLuckyGiftExperimentRequest) (*UpdateLuckyGiftExperimentResponse, error) + SetLuckyGiftExperimentOverrides(context.Context, *SetLuckyGiftExperimentOverridesRequest) (*SetLuckyGiftExperimentOverridesResponse, error) + EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error) mustEmbedUnimplementedAdminLuckyGiftServiceServer() } @@ -377,6 +442,21 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(contex func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) { return nil, status.Error(codes.Unimplemented, "method AdjustLuckyGiftPoolBalance not implemented") } +func (UnimplementedAdminLuckyGiftServiceServer) CreateLuckyGiftExperiment(context.Context, *CreateLuckyGiftExperimentRequest) (*CreateLuckyGiftExperimentResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateLuckyGiftExperiment not implemented") +} +func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftExperiments(context.Context, *ListLuckyGiftExperimentsRequest) (*ListLuckyGiftExperimentsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftExperiments not implemented") +} +func (UnimplementedAdminLuckyGiftServiceServer) UpdateLuckyGiftExperiment(context.Context, *UpdateLuckyGiftExperimentRequest) (*UpdateLuckyGiftExperimentResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateLuckyGiftExperiment not implemented") +} +func (UnimplementedAdminLuckyGiftServiceServer) SetLuckyGiftExperimentOverrides(context.Context, *SetLuckyGiftExperimentOverridesRequest) (*SetLuckyGiftExperimentOverridesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetLuckyGiftExperimentOverrides not implemented") +} +func (UnimplementedAdminLuckyGiftServiceServer) EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error) { + return nil, status.Error(codes.Unimplemented, "method EndLuckyGiftExperiment not implemented") +} func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {} func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {} @@ -524,6 +604,96 @@ func _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler(srv interface{}, return interceptor(ctx, in, info, handler) } +func _AdminLuckyGiftService_CreateLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateLuckyGiftExperimentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).CreateLuckyGiftExperiment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).CreateLuckyGiftExperiment(ctx, req.(*CreateLuckyGiftExperimentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AdminLuckyGiftService_ListLuckyGiftExperiments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListLuckyGiftExperimentsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftExperiments(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftExperiments(ctx, req.(*ListLuckyGiftExperimentsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AdminLuckyGiftService_UpdateLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateLuckyGiftExperimentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).UpdateLuckyGiftExperiment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).UpdateLuckyGiftExperiment(ctx, req.(*UpdateLuckyGiftExperimentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetLuckyGiftExperimentOverridesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).SetLuckyGiftExperimentOverrides(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).SetLuckyGiftExperimentOverrides(ctx, req.(*SetLuckyGiftExperimentOverridesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EndLuckyGiftExperimentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).EndLuckyGiftExperiment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).EndLuckyGiftExperiment(ctx, req.(*EndLuckyGiftExperimentRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -559,6 +729,26 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AdjustLuckyGiftPoolBalance", Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler, }, + { + MethodName: "CreateLuckyGiftExperiment", + Handler: _AdminLuckyGiftService_CreateLuckyGiftExperiment_Handler, + }, + { + MethodName: "ListLuckyGiftExperiments", + Handler: _AdminLuckyGiftService_ListLuckyGiftExperiments_Handler, + }, + { + MethodName: "UpdateLuckyGiftExperiment", + Handler: _AdminLuckyGiftService_UpdateLuckyGiftExperiment_Handler, + }, + { + MethodName: "SetLuckyGiftExperimentOverrides", + Handler: _AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_Handler, + }, + { + MethodName: "EndLuckyGiftExperiment", + Handler: _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "proto/luckygift/v1/luckygift.proto", diff --git a/docs/幸运礼物AB实验.md b/docs/幸运礼物AB实验.md new file mode 100644 index 00000000..d8d73076 --- /dev/null +++ b/docs/幸运礼物AB实验.md @@ -0,0 +1,121 @@ +# 幸运礼物 AB 实验(灰度放量) + +> 2026-07 引入。允许对同一 app + 奖池发布一个"实验组"配置版本,只对按用户哈希圈定的部分用户生效, +> 其余用户继续使用原版本;支持白名单验证、放量调整、暂停与选择获胜方收口。 + +## 一、动机与总体设计 + +幸运礼物配置是不可变版本流(`lucky_gift_rule_versions`,PK = app_code + pool_id + rule_version): +发布 = 追加 `max(rule_version)+1` 的新行,运行时永远取最新版本。因此**发布即全量**,没有灰度空间。 + +AB 实验在其上加一层"版本钉住"语义,不改变版本流本身: + +- 实验(`lucky_gift_experiments`)为一个奖池钉住两个已存在的不可变版本: + `control_rule_version`(对照组 = 创建实验时的最新启用版本)与 `treatment_rule_version`(实验组 = 随实验**同事务原子发布**的新版本)。 +- 有未完成实验时,抽奖/预检查按用户解析组并加载对应版本;无实验时保持"最新版本生效"原语义。 +- 同一奖池同时最多一个未完成实验(`active` 生成列 + 唯一键兜底)。 + +### 为什么"创建实验 = 原子发布 + 登记" + +若先正常发布 v10 再建实验,发布提交到实验生效之间存在窗口:此时 v10 已是最新版本,**全量用户瞬间切到 v10**。 +因此 `CreateLuckyGiftExperiment` 在一个 MySQL 事务里完成 treatment 发布与实验登记(`lucky_gift_experiment_repository.go`)。 +同理,实验进行中常规发布被拒绝(409):实验期两组各自钉住版本,新发布的"最新版本"不会生效,只会造成困惑。 + +### 分流算法 + +`FNV-1a 64(salt ":" userKey) % 1,000,000 < treatment_traffic_ppm → treatment`(`internal/domain/luckygift/experiment.go`)。 + +- userKey:内部用户为十进制 user_id;外部 App 用户为 `ext:` + external_user_id。两条抽奖链路共用同一分流。 + 白名单 user_key 列使用 utf8mb4_bin(字节精确),与 FNV 分桶口径一致。 +- **确定性**:实验状态不变时,同一用户在 Check 与 Draw 解析到同一组(room-service 送礼 saga 在 CheckLuckyGift + 固化 strategy_version、恢复阶段不重新 Check)。放量/白名单/暂停操作会让分组在两次解析间漂移,因此—— +- **两组必须同策略族**(创建实验强制校验):同策略下分组漂移只是参数差异,不会击穿 room saga 对 + device_id/paid_at 的扣费前拦截。fixed_v2 → dynamic_v3 的**策略迁移走常规发布**,不走实验 + (旧客户端本就无法进入 dynamic 组,混合策略实验的分组天然有偏差,统计上也不成立)。 +- **单调放量**:`bucket < traffic` 的比较方式保证调大流量时已在实验组的用户全部保留,只有新用户迁入; + 调小流量会把部分用户迁回对照组(用户状态跨版本共享,见下)。 +- 白名单(`lucky_gift_experiment_overrides`)优先于哈希,可强制任一组,用于放量前的真机验证(流量 0% + 白名单); + 单实验累计上限 500。 +- `paused` 是紧急停闸:全部流量(**含白名单**)回落对照组;恢复 running 后原分组不变。 + +### 与既有机制的关系(为什么这套结构对实验天然安全) + +| 机制 | 维度 | 实验语义 | +|---|---|---| +| RTP 窗口 `lucky_rtp_windows` | `pool:v{rule_version}` | 天然按组隔离,两组 RTP 观察互不污染 | +| 资金池 `lucky_pools` | fixed_v2: `pool`+poolID;dynamic_v3: `pool_dynamic_v3`+poolID | 两组同策略(强制),共享同一口支付能力,按组核算走抽奖事实 | +| 用户状态 `lucky_user_states`、日/72h RTP、六维风控 | 按用户/设备/房间,不含版本 | 跨组共享;用户粘在一组内,状态连续,不因实验重置保底/风控额度 | +| 抽奖事实 `lucky_draw_records` | 每抽落 `rule_version` | 分组统计免费:按 rule_version 聚合即得两组口径 | + +### 结束实验(收口) + +`EndLuckyGiftExperiment(winner_arm)` 在一个事务里:把获胜组钉住的配置**快照重发布**为新的最新版本 +(`final_rule_version`),并把实验标记 completed。结束瞬间"最新版本生效"的默认语义恰好收敛到获胜配置,无中间态: + +- treatment 胜 → 全量放开新配置(等效全量上线); +- control 胜 → 回滚,等效撤销本次变更(treatment 版本留在版本流里成为历史)。 + +注意:若 treatment 是 dynamic_v3 而 control 胜出,实验期物化的 dynamic 账本及其人工注资仍在,需要用现有"扣减水位"人工调账收回。 + +## 二、数据模型 + +`services/lucky-gift-service/deploy/mysql/migrations/006_ab_experiments.sql`(initdb 001 已同步): + +- `lucky_gift_experiments`:PK (app_code, experiment_id);`active TINYINT AS (IF(status IN ('running','paused'),1,NULL)) STORED`, + `UNIQUE (app_code, pool_id, active)` ⇒ 每池至多一个未完成实验,completed 历史不限量。 +- `lucky_gift_experiment_overrides`:PK (app_code, experiment_id, user_key),arm ∈ {control, treatment}。白名单上限 500(service 校验)。 + +## 三、运行时接入点(lucky-gift-service) + +`resolveLuckyGiftRuleConfigForUser`(`lucky_gift_experiment_repository.go`)替换了四处配置加载: + +1. `CheckLuckyGift`(送礼预检查,非锁读) +2. `executeSingleLuckyGiftDraw`(单抽,事务内 FOR UPDATE) +3. `executeOptimizedLuckyGiftDrawBatch`(批量抽,事务内 FOR UPDATE) +4. `ExecuteExternalGiftDraw`(外部 App,ext: 前缀 userKey) + +实验行用一致性快照读(不加锁),钉住的版本行按原语义加锁;两组锁不同版本行但仍在共享资金池行上串行, +锁序(规则行 → 实验行)在常规发布、创建实验、结束实验三个入口保持一致,无交叉死锁。 +钉住版本行缺失 = 不可变事实被外部破坏,fail-close 报错,不静默回落最新版本。 + +已知取舍(均无资金风险): +- 创建实验提交瞬间,个别在途抽奖事务可能按"最新版本=treatment"开奖一次(毫秒级窗口,两组配置都已校验可运行)。 +- 不同奖池并发创建实验可能在唯一索引间隙锁上触发一次 InnoDB 死锁回滚——管理端低频操作,报错重试即可。 +- 结束实验以获胜方快照重发布时,dynamic_v3 遗留的 initial_pool_coins 会被清零(与运行时读取口径一致)。 + +## 四、管理 API + +gRPC `luckygift.v1.AdminLuckyGiftService` 新增:CreateLuckyGiftExperiment / ListLuckyGiftExperiments(with_stats 返回分组聚合)/ +UpdateLuckyGiftExperiment(放量、暂停恢复)/ SetLuckyGiftExperimentOverrides / EndLuckyGiftExperiment。 + +admin HTTP(前缀 /api/v1/admin/ops-center,权限:查看 `lucky-gift:view`、管理 `lucky-gift:experiment`): + +| Method | Path | 说明 | +|---|---|---| +| GET | /lucky-gifts/experiments?app_code&pool_id&status&with_stats | 实验列表(含分组统计) | +| POST | /lucky-gifts/experiments?app_code | 创建(body: name + treatment_traffic_percent + overrides + config) | +| PATCH | /lucky-gifts/experiments/{id}?app_code | 放量 / 暂停 / 恢复 | +| PUT | /lucky-gifts/experiments/{id}/overrides?app_code | 白名单增删 | +| POST | /lucky-gifts/experiments/{id}/end?app_code | 结束并选择获胜方 | + +ops-center 前端在幸运礼物配置页集成:实验中奖池带徽章与"实验详情",行操作可"创建 AB 实验"(复用配置表单 + 实验字段), +管理弹窗支持放量滑调、白名单、分组指标对比与结束实验二次确认。 + +## 五、标准操作流程(以 lalu super_lucky 升级为例) + +1. 配置页 super_lucky 行 → "创建 AB 实验":填新配置内容、实验名,流量先设 0%,白名单加测试账号(arm=treatment)。 +2. 测试账号真机验证:送幸运礼物应命中新版本(抽奖记录 rule_version = treatment 版本)。 +3. 实验详情 → 放量 5% → 观察两组 RTP/消耗/独立用户(结算口径差异需累计足够流水才有意义)→ 20% → 50%。 +4. 异常随时"暂停"(全量回对照组,不销毁分组);修复后恢复。 +5. 数据达标 → "结束实验",选获胜方;系统重发布获胜配置为新版本并全量收敛。 + +## 六、发布与排障 + +- **迁移**:生产按既有流程手动执行 `006_ab_experiments.sql`(`mysql_auto_migrate` 不建表);本地 docker 新库由 initdb 覆盖, + 旧库需手动执行 006。测试环境同理。 +- 分组核对 SQL: + `SELECT rule_version, COUNT(*), SUM(coin_spent), SUM(effective_reward_coins) FROM lucky_draw_records + WHERE app_code=? AND pool_id=? AND created_at_ms>=? GROUP BY rule_version;` +- 某用户实际在哪组:查 `lucky_gift_experiment_overrides`;无 override 时按 salt 哈希(域函数 `ExperimentBucketPPM`, + 可用 strategy-sim 或临时 Go 片段复算)。 +- 真库回归:`LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN=... go test ./services/lucky-gift-service/internal/storage/mysql/ -run Experiment`。 diff --git a/server/admin/internal/integration/luckygiftadmin/client.go b/server/admin/internal/integration/luckygiftadmin/client.go index 0643d936..2bd9c241 100644 --- a/server/admin/internal/integration/luckygiftadmin/client.go +++ b/server/admin/internal/integration/luckygiftadmin/client.go @@ -18,6 +18,11 @@ type Client interface { GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) + CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) + ListLuckyGiftExperiments(ctx context.Context, req *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) + UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) + SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) + EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) } type GRPCClient struct { @@ -55,3 +60,23 @@ func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygi func (c *GRPCClient) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) { return c.client.AdjustLuckyGiftPoolBalance(ctx, req) } + +func (c *GRPCClient) CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) { + return c.client.CreateLuckyGiftExperiment(ctx, req) +} + +func (c *GRPCClient) ListLuckyGiftExperiments(ctx context.Context, req *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) { + return c.client.ListLuckyGiftExperiments(ctx, req) +} + +func (c *GRPCClient) UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) { + return c.client.UpdateLuckyGiftExperiment(ctx, req) +} + +func (c *GRPCClient) SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) { + return c.client.SetLuckyGiftExperimentOverrides(ctx, req) +} + +func (c *GRPCClient) EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) { + return c.client.EndLuckyGiftExperiment(ctx, req) +} diff --git a/server/admin/internal/modules/luckygift/experiments.go b/server/admin/internal/modules/luckygift/experiments.go new file mode 100644 index 00000000..38f1cc31 --- /dev/null +++ b/server/admin/internal/modules/luckygift/experiments.go @@ -0,0 +1,312 @@ +package luckygift + +import ( + "fmt" + "strings" + + "hyapp-admin-server/internal/middleware" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + luckygiftv1 "hyapp.local/api/proto/luckygift/v1" + + "github.com/gin-gonic/gin" +) + +type experimentOverrideDTO struct { + UserKey string `json:"user_key"` + Arm string `json:"arm"` +} + +type experimentArmStatsDTO struct { + Arm string `json:"arm"` + RuleVersion int64 `json:"rule_version"` + StrategyVersion string `json:"strategy_version"` + TotalDraws int64 `json:"total_draws"` + UniqueUsers int64 `json:"unique_users"` + TotalSpentCoins int64 `json:"total_spent_coins"` + TotalRewardCoins int64 `json:"total_reward_coins"` + ActualRTPPercent float64 `json:"actual_rtp_percent"` +} + +type experimentDTO struct { + AppCode string `json:"app_code"` + PoolID string `json:"pool_id"` + ExperimentID string `json:"experiment_id"` + Name string `json:"name"` + ControlRuleVersion int64 `json:"control_rule_version"` + TreatmentRuleVersion int64 `json:"treatment_rule_version"` + TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"` + Status string `json:"status"` + WinnerArm string `json:"winner_arm"` + WinnerRuleVersion int64 `json:"winner_rule_version"` + FinalRuleVersion int64 `json:"final_rule_version"` + CreatedByAdminID int64 `json:"created_by_admin_id"` + StartedAtMS int64 `json:"started_at_ms"` + EndedAtMS int64 `json:"ended_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` + Overrides []experimentOverrideDTO `json:"overrides"` + ArmStats []experimentArmStatsDTO `json:"arm_stats"` +} + +type createExperimentRequest struct { + Name string `json:"name"` + TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"` + Overrides []experimentOverrideDTO `json:"overrides"` + Config configRequest `json:"config"` +} + +type updateExperimentRequest struct { + // 指针区分「没传」与「显式传 0」:缺省时向 owner 传 -1 表示保持现有放量。 + TreatmentTrafficPercent *float64 `json:"treatment_traffic_percent"` + Status string `json:"status"` +} + +type setExperimentOverridesRequest struct { + Upserts []experimentOverrideDTO `json:"upserts"` + Removes []string `json:"removes"` +} + +type endExperimentRequest struct { + WinnerArm string `json:"winner_arm"` +} + +func (h *Handler) ListLuckyGiftExperiments(c *gin.Context) { + if strings.TrimSpace(c.Query("app_code")) == "" { + response.BadRequest(c, "app_code is required") + return + } + ctx, cancel := h.luckyGiftContext(c) + defer cancel() + resp, err := h.luckyGift.ListLuckyGiftExperiments(ctx, &luckygiftv1.ListLuckyGiftExperimentsRequest{ + Meta: h.meta(c), + PoolId: strings.TrimSpace(c.Query("pool_id")), + Status: strings.ToLower(strings.TrimSpace(c.Query("status"))), + WithStats: parseOptionalBool(c.Query("with_stats")), + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + items := make([]experimentDTO, 0, len(resp.GetExperiments())) + for _, experiment := range resp.GetExperiments() { + items = append(items, experimentFromProto(experiment)) + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) CreateLuckyGiftExperiment(c *gin.Context) { + appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code"))) + if appCode == "" { + response.BadRequest(c, "app_code is required") + return + } + var req createExperimentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "幸运礼物实验参数不正确") + return + } + if req.Config.AppCode == "" { + req.Config.AppCode = appCode + } + ctx, cancel := h.luckyGiftContext(c) + defer cancel() + resp, err := h.luckyGift.CreateLuckyGiftExperiment(ctx, &luckygiftv1.CreateLuckyGiftExperimentRequest{ + Meta: h.metaForApp(c, appCode), + Name: strings.TrimSpace(req.Name), + // treatment 配置复用 PUT /lucky-gifts/config 的解析归一逻辑(tier_id 生成、percent→ppm、 + // dynamic_v3 initial_pool_coins 清零),保证实验发布的新版本与常规发布口径一致。 + TreatmentConfig: configToProto(req.Config), + TreatmentTrafficPpm: percentToPPM(req.TreatmentTrafficPercent), + OperatorAdminId: int64(middleware.CurrentUserID(c)), + Overrides: experimentOverridesToProto(req.Overrides), + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + experiment := experimentFromProto(resp.GetExperiment()) + shared.OperationLogWithResourceID( + c, h.audit, "create-lucky-gift-experiment", "lucky_gift_experiments", experiment.ExperimentID, "success", + fmt.Sprintf("app_code=%s pool_id=%s treatment_traffic_ppm=%d", appCode, experiment.PoolID, percentToPPM(req.TreatmentTrafficPercent)), + ) + response.OK(c, gin.H{ + "experiment": experiment, + "treatment_config": configFromProto(resp.GetTreatmentConfig()), + }) +} + +func (h *Handler) UpdateLuckyGiftExperiment(c *gin.Context) { + appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code"))) + if appCode == "" { + response.BadRequest(c, "app_code is required") + return + } + experimentID := strings.TrimSpace(c.Param("experimentId")) + if experimentID == "" { + response.BadRequest(c, "experiment_id is required") + return + } + var req updateExperimentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "幸运礼物实验参数不正确") + return + } + trafficPPM := int64(-1) + grpcReq := &luckygiftv1.UpdateLuckyGiftExperimentRequest{ + Meta: h.metaForApp(c, appCode), + ExperimentId: experimentID, + Status: strings.ToLower(strings.TrimSpace(req.Status)), + OperatorAdminId: int64(middleware.CurrentUserID(c)), + } + if req.TreatmentTrafficPercent != nil { + trafficPPM = percentToPPM(*req.TreatmentTrafficPercent) + grpcReq.TreatmentTrafficPpm = &trafficPPM + } + ctx, cancel := h.luckyGiftContext(c) + defer cancel() + resp, err := h.luckyGift.UpdateLuckyGiftExperiment(ctx, grpcReq) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID( + c, h.audit, "update-lucky-gift-experiment", "lucky_gift_experiments", experimentID, "success", + fmt.Sprintf("app_code=%s treatment_traffic_ppm=%d status=%s", appCode, trafficPPM, strings.ToLower(strings.TrimSpace(req.Status))), + ) + response.OK(c, gin.H{"experiment": experimentFromProto(resp.GetExperiment())}) +} + +func (h *Handler) SetLuckyGiftExperimentOverrides(c *gin.Context) { + appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code"))) + if appCode == "" { + response.BadRequest(c, "app_code is required") + return + } + experimentID := strings.TrimSpace(c.Param("experimentId")) + if experimentID == "" { + response.BadRequest(c, "experiment_id is required") + return + } + var req setExperimentOverridesRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "幸运礼物实验白名单参数不正确") + return + } + removes := make([]string, 0, len(req.Removes)) + for _, userKey := range req.Removes { + removes = append(removes, strings.TrimSpace(userKey)) + } + ctx, cancel := h.luckyGiftContext(c) + defer cancel() + resp, err := h.luckyGift.SetLuckyGiftExperimentOverrides(ctx, &luckygiftv1.SetLuckyGiftExperimentOverridesRequest{ + Meta: h.metaForApp(c, appCode), + ExperimentId: experimentID, + Upserts: experimentOverridesToProto(req.Upserts), + Removes: removes, + OperatorAdminId: int64(middleware.CurrentUserID(c)), + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID( + c, h.audit, "set-lucky-gift-experiment-overrides", "lucky_gift_experiments", experimentID, "success", + fmt.Sprintf("app_code=%s upserts=%d removes=%d", appCode, len(req.Upserts), len(removes)), + ) + response.OK(c, gin.H{"experiment": experimentFromProto(resp.GetExperiment())}) +} + +func (h *Handler) EndLuckyGiftExperiment(c *gin.Context) { + appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code"))) + if appCode == "" { + response.BadRequest(c, "app_code is required") + return + } + experimentID := strings.TrimSpace(c.Param("experimentId")) + if experimentID == "" { + response.BadRequest(c, "experiment_id is required") + return + } + var req endExperimentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "幸运礼物实验参数不正确") + return + } + winnerArm := strings.ToLower(strings.TrimSpace(req.WinnerArm)) + ctx, cancel := h.luckyGiftContext(c) + defer cancel() + resp, err := h.luckyGift.EndLuckyGiftExperiment(ctx, &luckygiftv1.EndLuckyGiftExperimentRequest{ + Meta: h.metaForApp(c, appCode), + ExperimentId: experimentID, + WinnerArm: winnerArm, + OperatorAdminId: int64(middleware.CurrentUserID(c)), + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLogWithResourceID( + c, h.audit, "end-lucky-gift-experiment", "lucky_gift_experiments", experimentID, "success", + fmt.Sprintf("app_code=%s winner_arm=%s", appCode, winnerArm), + ) + response.OK(c, gin.H{ + "experiment": experimentFromProto(resp.GetExperiment()), + "final_config": configFromProto(resp.GetFinalConfig()), + }) +} + +func experimentOverridesToProto(overrides []experimentOverrideDTO) []*luckygiftv1.LuckyGiftExperimentOverride { + out := make([]*luckygiftv1.LuckyGiftExperimentOverride, 0, len(overrides)) + for _, override := range overrides { + out = append(out, &luckygiftv1.LuckyGiftExperimentOverride{ + UserKey: strings.TrimSpace(override.UserKey), + Arm: strings.ToLower(strings.TrimSpace(override.Arm)), + }) + } + return out +} + +func experimentFromProto(experiment *luckygiftv1.LuckyGiftExperiment) experimentDTO { + if experiment == nil { + return experimentDTO{Overrides: []experimentOverrideDTO{}, ArmStats: []experimentArmStatsDTO{}} + } + overrides := make([]experimentOverrideDTO, 0, len(experiment.GetOverrides())) + for _, override := range experiment.GetOverrides() { + overrides = append(overrides, experimentOverrideDTO{ + UserKey: override.GetUserKey(), + Arm: override.GetArm(), + }) + } + armStats := make([]experimentArmStatsDTO, 0, len(experiment.GetArmStats())) + for _, stats := range experiment.GetArmStats() { + armStats = append(armStats, experimentArmStatsDTO{ + Arm: stats.GetArm(), + RuleVersion: stats.GetRuleVersion(), + StrategyVersion: stats.GetStrategyVersion(), + TotalDraws: stats.GetTotalDraws(), + UniqueUsers: stats.GetUniqueUsers(), + TotalSpentCoins: stats.GetTotalSpentCoins(), + TotalRewardCoins: stats.GetTotalRewardCoins(), + ActualRTPPercent: ppmToPercent(stats.GetActualRtpPpm()), + }) + } + return experimentDTO{ + AppCode: experiment.GetAppCode(), + PoolID: experiment.GetPoolId(), + ExperimentID: experiment.GetExperimentId(), + Name: experiment.GetName(), + ControlRuleVersion: experiment.GetControlRuleVersion(), + TreatmentRuleVersion: experiment.GetTreatmentRuleVersion(), + TreatmentTrafficPercent: ppmToPercent(experiment.GetTreatmentTrafficPpm()), + Status: experiment.GetStatus(), + WinnerArm: experiment.GetWinnerArm(), + WinnerRuleVersion: experiment.GetWinnerRuleVersion(), + FinalRuleVersion: experiment.GetFinalRuleVersion(), + CreatedByAdminID: experiment.GetCreatedByAdminId(), + StartedAtMS: experiment.GetStartedAtMs(), + EndedAtMS: experiment.GetEndedAtMs(), + UpdatedAtMS: experiment.GetUpdatedAtMs(), + Overrides: overrides, + ArmStats: armStats, + } +} diff --git a/server/admin/internal/modules/luckygift/experiments_test.go b/server/admin/internal/modules/luckygift/experiments_test.go new file mode 100644 index 00000000..4efcb70c --- /dev/null +++ b/server/admin/internal/modules/luckygift/experiments_test.go @@ -0,0 +1,277 @@ +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()) + } +} diff --git a/server/admin/internal/modules/opscenter/routes.go b/server/admin/internal/modules/opscenter/routes.go index 4b7dfb7b..f3433314 100644 --- a/server/admin/internal/modules/opscenter/routes.go +++ b/server/admin/internal/modules/opscenter/routes.go @@ -27,4 +27,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { group.GET("/lucky-gifts/pools", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftPoolBalances) group.POST("/lucky-gifts/pools/credit", middleware.RequirePermission("lucky-gift:pool-credit"), h.luckyGift.CreditLuckyGiftPoolBalance) group.POST("/lucky-gifts/pools/debit", middleware.RequirePermission("lucky-gift:pool-debit"), h.luckyGift.DebitLuckyGiftPoolBalance) + group.GET("/lucky-gifts/experiments", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftExperiments) + group.POST("/lucky-gifts/experiments", middleware.RequirePermission("lucky-gift:experiment"), h.luckyGift.CreateLuckyGiftExperiment) + group.PATCH("/lucky-gifts/experiments/:experimentId", middleware.RequirePermission("lucky-gift:experiment"), h.luckyGift.UpdateLuckyGiftExperiment) + group.PUT("/lucky-gifts/experiments/:experimentId/overrides", middleware.RequirePermission("lucky-gift:experiment"), h.luckyGift.SetLuckyGiftExperimentOverrides) + group.POST("/lucky-gifts/experiments/:experimentId/end", middleware.RequirePermission("lucky-gift:experiment"), h.luckyGift.EndLuckyGiftExperiment) } diff --git a/server/admin/internal/modules/opscenter/routes_test.go b/server/admin/internal/modules/opscenter/routes_test.go index ba218421..8ddc461e 100644 --- a/server/admin/internal/modules/opscenter/routes_test.go +++ b/server/admin/internal/modules/opscenter/routes_test.go @@ -42,6 +42,11 @@ func TestRegisterRoutesExposesStrategyPoolAdjustmentEndpoints(t *testing.T) { for _, expected := range []string{ "POST /api/v1/admin/ops-center/lucky-gifts/pools/credit", "POST /api/v1/admin/ops-center/lucky-gifts/pools/debit", + "GET /api/v1/admin/ops-center/lucky-gifts/experiments", + "POST /api/v1/admin/ops-center/lucky-gifts/experiments", + "PATCH /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId", + "PUT /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId/overrides", + "POST /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId/end", } { if _, ok := routes[expected]; !ok { t.Fatalf("missing route %s; actual=%v", expected, routes) @@ -49,6 +54,95 @@ func TestRegisterRoutesExposesStrategyPoolAdjustmentEndpoints(t *testing.T) { } } +type experimentPermissionClient struct { + luckygiftadmin.Client +} + +func (experimentPermissionClient) ListLuckyGiftExperiments(_ context.Context, _ *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) { + return &luckygiftv1.ListLuckyGiftExperimentsResponse{}, nil +} + +func (experimentPermissionClient) CreateLuckyGiftExperiment(_ context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) { + return &luckygiftv1.CreateLuckyGiftExperimentResponse{ + Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: "lucky_exp_route", Name: req.GetName()}, + TreatmentConfig: req.GetTreatmentConfig(), + }, nil +} + +func (experimentPermissionClient) UpdateLuckyGiftExperiment(_ context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) { + return &luckygiftv1.UpdateLuckyGiftExperimentResponse{ + Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: req.GetExperimentId()}, + }, nil +} + +func TestExperimentRoutesRequireExperimentPermission(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + method string + target string + body string + permission string + wantStatus int + }{ + { + name: "list allowed with view", method: http.MethodGet, + target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu", + body: "", permission: "lucky-gift:view", wantStatus: http.StatusOK, + }, + { + name: "create allowed with experiment", method: http.MethodPost, + target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu", + body: `{"name":"exp","treatment_traffic_percent":5,"config":{"pool_id":"lucky"}}`, + permission: "lucky-gift:experiment", wantStatus: http.StatusOK, + }, + { + name: "create rejects update permission", method: http.MethodPost, + target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu", + body: `{"name":"exp","treatment_traffic_percent":5,"config":{"pool_id":"lucky"}}`, + permission: "lucky-gift:update", wantStatus: http.StatusForbidden, + }, + { + name: "patch allowed with experiment", method: http.MethodPatch, + target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route?app_code=lalu", + body: `{"status":"paused"}`, permission: "lucky-gift:experiment", wantStatus: http.StatusOK, + }, + { + name: "patch rejects view permission", method: http.MethodPatch, + target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route?app_code=lalu", + body: `{"status":"paused"}`, permission: "lucky-gift:view", wantStatus: http.StatusForbidden, + }, + { + name: "end rejects view permission", method: http.MethodPost, + target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route/end?app_code=lalu", + body: `{"winner_arm":"control"}`, permission: "lucky-gift:view", wantStatus: http.StatusForbidden, + }, + { + name: "overrides rejects update permission", method: http.MethodPut, + target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route/overrides?app_code=lalu", + body: `{"upserts":[]}`, permission: "lucky-gift:update", wantStatus: http.StatusForbidden, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set(middleware.ContextPermissions, []string{tt.permission}) + c.Set(middleware.ContextUserID, uint(7)) + c.Next() + }) + RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(experimentPermissionClient{}, 0, nil))) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body)) + request.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(recorder, request) + if recorder.Code != tt.wantStatus { + t.Fatalf("status=%d want=%d body=%s", recorder.Code, tt.wantStatus, recorder.Body.String()) + } + }) + } +} + func TestPoolAdjustmentRoutesRequireDirectionSpecificPermission(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { diff --git a/server/admin/internal/repository/role_permission_matrix.go b/server/admin/internal/repository/role_permission_matrix.go index 6cba25fc..9175b682 100644 --- a/server/admin/internal/repository/role_permission_matrix.go +++ b/server/admin/internal/repository/role_permission_matrix.go @@ -140,7 +140,7 @@ var ( "wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle", "agency-opening:create", "agency-opening:update", "red-packet:update", "cp-config:update", "cp-weekly-rank:update", "vip-config:update", - "achievement:create", "achievement:update", "lucky-gift:update", + "achievement:create", "achievement:update", "lucky-gift:update", "lucky-gift:experiment", } // 奖池人工增减属于资金高风险动作,只授予运营负责人;platform-admin 仍由全权限同步自动获得。 luckyGiftPoolManage = []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"} diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index c1b9d3a9..7ec4e46e 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -204,6 +204,7 @@ var defaultPermissions = []model.Permission{ {Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"}, {Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"}, {Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"}, + {Name: "幸运礼物实验管理", Code: "lucky-gift:experiment", Kind: "button"}, {Name: "幸运礼物奖池注资", Code: "lucky-gift:pool-credit", Kind: "button"}, {Name: "幸运礼物奖池扣资", Code: "lucky-gift:pool-debit", Kind: "button"}, {Name: "转盘抽奖查看", Code: "wheel:view", Kind: "menu"}, diff --git a/server/admin/internal/repository/seed_test.go b/server/admin/internal/repository/seed_test.go index 68930ea6..38ea496e 100644 --- a/server/admin/internal/repository/seed_test.go +++ b/server/admin/internal/repository/seed_test.go @@ -162,16 +162,16 @@ func TestManagedDefaultRoleModuleBoundaries(t *testing.T) { func TestLuckyGiftPermissionsStayInManagedRoleSync(t *testing.T) { assertRolePermissions(t, roleCodeOperationsLead, - []string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, nil, + []string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, nil, ) assertRolePermissions(t, roleCodeOperationsSpecialist, - []string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, + []string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, ) assertRolePermissions(t, roleCodeProductLead, - []string{"lucky-gift:view", "lucky-gift:update"}, []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"}, + []string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:experiment"}, []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"}, ) assertRolePermissions(t, roleCodeProductSpecialist, - []string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, + []string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, ) } @@ -358,6 +358,28 @@ func TestLuckyGiftPoolAdjustmentPermissionMigrationRepairsManagedRoles(t *testin } } +func TestLuckyGiftExperimentPermissionMigrationGrantsUpdateRoleGroup(t *testing.T) { + content, err := os.ReadFile("../../migrations/103_lucky_gift_experiment_permission.sql") + if err != nil { + t.Fatalf("read lucky gift experiment permission migration: %v", err) + } + sqlText := string(content) + for _, token := range []string{ + "'lucky-gift:experiment'", + "'platform-admin', 'ops-admin', 'product-lead'", + } { + if !strings.Contains(sqlText, token) { + t.Fatalf("lucky gift experiment permission migration missing %s", token) + } + } + // 实验会发布规则版本,但不是资金动作;不能顺手把奖池调账角色组扩进来。 + for _, token := range []string{"'operations-specialist'", "'product-specialist'", "'lucky-gift:pool-credit'", "'lucky-gift:pool-debit'"} { + if strings.Contains(sqlText, token) { + t.Fatalf("lucky gift experiment permission migration must not touch %s", token) + } + } +} + func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) { t.Helper() quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`) @@ -374,7 +396,7 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin "activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish", "activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry", "external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password", - "lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit", + "lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit", "lucky-gift:experiment", }) if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist { // 093 仍记录旧的财务提现权限;102 才把固定运营岗位切换到独立运营审核权限。 diff --git a/server/admin/migrations/103_lucky_gift_experiment_permission.sql b/server/admin/migrations/103_lucky_gift_experiment_permission.sql new file mode 100644 index 00000000..119af3e8 --- /dev/null +++ b/server/admin/migrations/103_lucky_gift_experiment_permission.sql @@ -0,0 +1,18 @@ +-- 幸运礼物 AB 实验管理是独立按钮权限:创建/调量/白名单/结束实验都会发布或钉住规则版本, +-- 权限边界与 lucky-gift:update 一致(platform-admin、ops-admin、product-lead),查看仍复用 lucky-gift:view。 +SET @now_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('幸运礼物实验管理', 'lucky-gift:experiment', 'button', '允许创建、放量、暂停、白名单和结束幸运礼物 AB 实验', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code IN ('platform-admin', 'ops-admin', 'product-lead') + AND permission.code = 'lucky-gift:experiment'; diff --git a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql index fe00e01d..942db2a2 100644 --- a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql +++ b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql @@ -309,7 +309,8 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records ( KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms), KEY idx_lucky_draw_created (app_code, created_at_ms, draw_id), KEY idx_lucky_draw_pool_status_summary (app_code, pool_id, reward_status, created_at_ms, draw_id), - KEY idx_lucky_draw_gift_status_summary (app_code, pool_id, gift_id, reward_status, created_at_ms, draw_id) + KEY idx_lucky_draw_gift_status_summary (app_code, pool_id, gift_id, reward_status, created_at_ms, draw_id), + KEY idx_lucky_draw_pool_version (app_code, pool_id, rule_version, created_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物抽奖事实'; CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats ( @@ -431,3 +432,41 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items ( UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id), KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计'; + +-- AB 实验事实表:两组各钉住一个不可变 rule_version,运行时按 bucket_salt + user 哈希分流。 +-- active 生成列只在未完成状态为 1,配合唯一键保证同一奖池同时最多一个未完成实验。 +CREATE TABLE IF NOT EXISTS lucky_gift_experiments ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + experiment_id VARCHAR(64) NOT NULL COMMENT '实验 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + name VARCHAR(128) NOT NULL COMMENT '实验名称', + control_rule_version BIGINT NOT NULL COMMENT '对照组钉住的规则版本', + treatment_rule_version BIGINT NOT NULL COMMENT '实验组钉住的规则版本', + treatment_traffic_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '实验组流量比例 ppm,0..1000000', + bucket_salt VARCHAR(64) NOT NULL COMMENT '分桶盐;实验生命周期内不可变,保证同一用户分组稳定', + status VARCHAR(16) NOT NULL DEFAULT 'running' COMMENT 'running/paused/completed', + active TINYINT AS (IF(status IN ('running', 'paused'), 1, NULL)) STORED COMMENT '未完成实验为 1,completed 为 NULL', + winner_arm VARCHAR(16) NOT NULL DEFAULT '' COMMENT '结束实验时选择的获胜组 control/treatment', + winner_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '获胜组钉住的规则版本', + final_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '结束时重发布的收敛版本,全量流量由它承接', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID', + started_at_ms BIGINT NOT NULL COMMENT '实验开始时间,UTC epoch ms', + ended_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实验结束时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, experiment_id), + UNIQUE KEY uk_lucky_gift_experiments_active_pool (app_code, pool_id, active), + KEY idx_lucky_gift_experiments_pool (app_code, pool_id, started_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 AB 实验'; + +-- 白名单 override:把指定用户强制固定到某个组,用于放量前验证。 +CREATE TABLE IF NOT EXISTS lucky_gift_experiment_overrides ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + experiment_id VARCHAR(64) NOT NULL COMMENT '实验 ID', + -- user_key 必须字节精确(binary collation):FNV 分桶按字节计算,ci 排序规则会让大小写不同的外部用户碰撞/失配。 + user_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户标识:内部十进制 user_id 或 ext:external_user_id', + arm VARCHAR(16) NOT NULL COMMENT '强制组 control/treatment', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '操作管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (app_code, experiment_id, user_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 AB 实验用户白名单'; diff --git a/services/lucky-gift-service/deploy/mysql/migrations/006_ab_experiments.sql b/services/lucky-gift-service/deploy/mysql/migrations/006_ab_experiments.sql new file mode 100644 index 00000000..b3687ca3 --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/006_ab_experiments.sql @@ -0,0 +1,53 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_lucky_gift; + +-- AB 实验事实表:两组各钉住一个不可变 rule_version,运行时按 bucket_salt + user 哈希分流。 +-- active 生成列只在未完成状态为 1,配合唯一键保证同一奖池同时最多一个未完成实验; +-- completed 后 active 为 NULL,不占用唯一键,历史实验可以保留任意多条。 +CREATE TABLE IF NOT EXISTS lucky_gift_experiments ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + experiment_id VARCHAR(64) NOT NULL COMMENT '实验 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + name VARCHAR(128) NOT NULL COMMENT '实验名称', + control_rule_version BIGINT NOT NULL COMMENT '对照组钉住的规则版本', + treatment_rule_version BIGINT NOT NULL COMMENT '实验组钉住的规则版本', + treatment_traffic_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '实验组流量比例 ppm,0..1000000', + bucket_salt VARCHAR(64) NOT NULL COMMENT '分桶盐;实验生命周期内不可变,保证同一用户分组稳定', + status VARCHAR(16) NOT NULL DEFAULT 'running' COMMENT 'running/paused/completed', + active TINYINT AS (IF(status IN ('running', 'paused'), 1, NULL)) STORED COMMENT '未完成实验为 1,completed 为 NULL', + winner_arm VARCHAR(16) NOT NULL DEFAULT '' COMMENT '结束实验时选择的获胜组 control/treatment', + winner_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '获胜组钉住的规则版本', + final_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '结束时重发布的收敛版本,全量流量由它承接', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID', + started_at_ms BIGINT NOT NULL COMMENT '实验开始时间,UTC epoch ms', + ended_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实验结束时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, experiment_id), + UNIQUE KEY uk_lucky_gift_experiments_active_pool (app_code, pool_id, active), + KEY idx_lucky_gift_experiments_pool (app_code, pool_id, started_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 AB 实验'; + +-- 白名单 override:把指定用户强制固定到某个组,用于放量前验证。 +-- user_key:内部用户为十进制 user_id;外部用户为 "ext:" + external_user_id。 +CREATE TABLE IF NOT EXISTS lucky_gift_experiment_overrides ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + experiment_id VARCHAR(64) NOT NULL COMMENT '实验 ID', + -- user_key 必须字节精确(binary collation):FNV 分桶按字节计算,ci 排序规则会让大小写不同的外部用户在白名单上碰撞/静默失配。 + user_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户标识:内部十进制 user_id 或 ext:external_user_id', + arm VARCHAR(16) NOT NULL COMMENT '强制组 control/treatment', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '操作管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + PRIMARY KEY (app_code, experiment_id, user_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 AB 实验用户白名单'; + +-- 实验分组统计按 (app, pool, rule_version, 时间窗) 聚合;没有含 rule_version 的索引时热池会退化为大范围回表扫描。 +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_draw_records' AND INDEX_NAME = 'idx_lucky_draw_pool_version') = 0, + 'ALTER TABLE lucky_draw_records ADD KEY idx_lucky_draw_pool_version (app_code, pool_id, rule_version, created_at_ms)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 兜底:006 首版曾以表默认 ci 排序规则建过 overrides 表;重复执行时归一到 binary。 +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_experiment_overrides' AND COLUMN_NAME = 'user_key' AND COLLATION_NAME <> 'utf8mb4_bin') = 1, + 'ALTER TABLE lucky_gift_experiment_overrides MODIFY user_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT ''用户标识:内部十进制 user_id 或 ext:external_user_id''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/services/lucky-gift-service/internal/domain/luckygift/experiment.go b/services/lucky-gift-service/internal/domain/luckygift/experiment.go new file mode 100644 index 00000000..a7f775b7 --- /dev/null +++ b/services/lucky-gift-service/internal/domain/luckygift/experiment.go @@ -0,0 +1,156 @@ +package luckygift + +import ( + "hash/fnv" + "strconv" + "strings" +) + +const ( + // 实验组常量。control 钉住实验开始时的最新已启用版本,treatment 钉住随实验原子发布的新版本。 + ExperimentArmControl = "control" + ExperimentArmTreatment = "treatment" + + // paused 时全部流量(含白名单 override)回落 control,作为不销毁分组事实的紧急停闸。 + ExperimentStatusRunning = "running" + ExperimentStatusPaused = "paused" + ExperimentStatusCompleted = "completed" + + // ExperimentTrafficPPMScale 是 treatment 放量比例的满量程;与全仓 ppm 口径一致。 + ExperimentTrafficPPMScale int64 = 1_000_000 + + // MaxExperimentOverrides 是单实验白名单的累计上限(单次请求与库内总量都受它约束)。 + // 白名单是放量前的验证工具,不是花名册式定向投放。 + MaxExperimentOverrides = 500 +) + +// Experiment 是单奖池 AB 实验事实。两组各自钉住一个不可变 rule_version, +// 运行中"最新版本生效"的默认语义被实验钉住的版本替代;同一奖池最多一个未完成实验。 +type Experiment struct { + AppCode string `json:"app_code"` + PoolID string `json:"pool_id"` + ExperimentID string `json:"experiment_id"` + Name string `json:"name"` + ControlRuleVersion int64 `json:"control_rule_version"` + TreatmentRuleVersion int64 `json:"treatment_rule_version"` + // TreatmentTrafficPPM ∈ [0, 1000000];0 表示只有白名单 override 进入 treatment。 + TreatmentTrafficPPM int64 `json:"treatment_traffic_ppm"` + BucketSalt string `json:"bucket_salt"` + Status string `json:"status"` + WinnerArm string `json:"winner_arm"` + WinnerRuleVersion int64 `json:"winner_rule_version"` + // FinalRuleVersion 是结束实验时把获胜配置重发布成的新最新版本;全量流量由它承接。 + FinalRuleVersion int64 `json:"final_rule_version"` + CreatedByAdminID int64 `json:"created_by_admin_id"` + StartedAtMS int64 `json:"started_at_ms"` + EndedAtMS int64 `json:"ended_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` + Overrides []ExperimentOverride `json:"overrides,omitempty"` + ArmStats []ExperimentArmStats `json:"arm_stats,omitempty"` +} + +// ExperimentOverride 把单个用户强制固定到某个实验组,多用于放量前的白名单验证。 +type ExperimentOverride struct { + UserKey string `json:"user_key"` + Arm string `json:"arm"` +} + +// ExperimentArmStats 是实验开始以来单组的抽奖聚合,按 rule_version 从抽奖事实现算。 +type ExperimentArmStats struct { + Arm string `json:"arm"` + RuleVersion int64 `json:"rule_version"` + StrategyVersion string `json:"strategy_version"` + TotalDraws int64 `json:"total_draws"` + UniqueUsers int64 `json:"unique_users"` + TotalSpentCoins int64 `json:"total_spent_coins"` + TotalRewardCoins int64 `json:"total_reward_coins"` + ActualRTPPPM int64 `json:"actual_rtp_ppm"` +} + +type CreateExperimentCommand struct { + Name string + TreatmentConfig RuleConfig + TreatmentTrafficPPM int64 + OperatorAdminID int64 + Overrides []ExperimentOverride +} + +type UpdateExperimentCommand struct { + ExperimentID string + // TreatmentTrafficPPM < 0 表示不修改流量。 + TreatmentTrafficPPM int64 + // Status 为空表示不修改状态;仅允许 running/paused 之间切换。 + Status string + OperatorAdminID int64 +} + +type SetExperimentOverridesCommand struct { + ExperimentID string + Upserts []ExperimentOverride + Removes []string + OperatorAdminID int64 +} + +type EndExperimentCommand struct { + ExperimentID string + WinnerArm string + OperatorAdminID int64 +} + +type ExperimentQuery struct { + AppCode string + PoolID string + Status string + WithStats bool +} + +// ExperimentUserKey 归一实验分桶与白名单使用的用户标识: +// 内部用户为十进制 user_id,外部用户为 "ext:" + external_user_id。 +func ExperimentUserKey(userID int64) string { + return strconv.FormatInt(userID, 10) +} + +func ExperimentExternalUserKey(externalUserID string) string { + return "ext:" + strings.TrimSpace(externalUserID) +} + +// ExperimentBucketPPM 把用户确定性地映射到 [0, 1000000) 的分桶坐标。 +// 对同一 salt+userKey 必须稳定:实验状态不变时 Check 与 Draw 解析到同一组。 +// 放量/白名单/暂停操作仍会让分组在两次解析间漂移——这正是创建实验强制两组同策略的原因: +// 同策略下漂移只是参数差异,不会击穿 room saga 固化 strategy_version 后对 device/paid_at 的前置拦截。 +func ExperimentBucketPPM(salt string, userKey string) int64 { + hasher := fnv.New64a() + _, _ = hasher.Write([]byte(salt)) + _, _ = hasher.Write([]byte{':'}) + _, _ = hasher.Write([]byte(userKey)) + return int64(hasher.Sum64() % uint64(ExperimentTrafficPPMScale)) +} + +// ResolveExperimentArm 返回用户所属实验组。 +// 优先级:paused 全量 control > 白名单 override > 哈希放量。 +// 放量比较使用 bucket < traffic:调大流量时既有 treatment 用户全部保留,只有新用户迁入,保证单调放量。 +func ResolveExperimentArm(experiment Experiment, userKey string, override string) string { + if experiment.Status != ExperimentStatusRunning { + return ExperimentArmControl + } + switch override { + case ExperimentArmControl, ExperimentArmTreatment: + return override + } + if ExperimentBucketPPM(experiment.BucketSalt, userKey) < experiment.TreatmentTrafficPPM { + return ExperimentArmTreatment + } + return ExperimentArmControl +} + +// ExperimentArmRuleVersion 返回某组钉住的配置版本。 +func ExperimentArmRuleVersion(experiment Experiment, arm string) int64 { + if arm == ExperimentArmTreatment { + return experiment.TreatmentRuleVersion + } + return experiment.ControlRuleVersion +} + +func ValidExperimentArm(arm string) bool { + return arm == ExperimentArmControl || arm == ExperimentArmTreatment +} diff --git a/services/lucky-gift-service/internal/domain/luckygift/experiment_test.go b/services/lucky-gift-service/internal/domain/luckygift/experiment_test.go new file mode 100644 index 00000000..3e47dab8 --- /dev/null +++ b/services/lucky-gift-service/internal/domain/luckygift/experiment_test.go @@ -0,0 +1,82 @@ +package luckygift + +import "testing" + +// 分桶必须纯确定:room saga 在 Check 固化 strategy_version 且恢复不重新 Check, +// 同一用户在 Check 与 Draw 的两次解析必须得到同一组。 +func TestExperimentBucketPPMIsDeterministic(t *testing.T) { + for _, userKey := range []string{"1", "42", "9007199254740993", "ext:abc-123"} { + first := ExperimentBucketPPM("salt-a", userKey) + for i := 0; i < 10; i++ { + if again := ExperimentBucketPPM("salt-a", userKey); again != first { + t.Fatalf("bucket for %q drifted: %d != %d", userKey, again, first) + } + } + if first < 0 || first >= ExperimentTrafficPPMScale { + t.Fatalf("bucket for %q out of range: %d", userKey, first) + } + } + if ExperimentBucketPPM("salt-a", "1") == ExperimentBucketPPM("salt-b", "1") && + ExperimentBucketPPM("salt-a", "2") == ExperimentBucketPPM("salt-b", "2") && + ExperimentBucketPPM("salt-a", "3") == ExperimentBucketPPM("salt-b", "3") { + t.Fatal("different salts should reshuffle buckets") + } +} + +// 放量必须单调:调大流量时既有 treatment 用户全部保留,不允许来回倒组打断用户体验状态。 +func TestResolveExperimentArmMonotonicRamp(t *testing.T) { + low := Experiment{Status: ExperimentStatusRunning, BucketSalt: "s", TreatmentTrafficPPM: 50_000} + high := low + high.TreatmentTrafficPPM = 300_000 + treatmentAtLow := 0 + for userID := int64(1); userID <= 2_000; userID++ { + key := ExperimentUserKey(userID) + lowArm := ResolveExperimentArm(low, key, "") + highArm := ResolveExperimentArm(high, key, "") + if lowArm == ExperimentArmTreatment { + treatmentAtLow++ + if highArm != ExperimentArmTreatment { + t.Fatalf("user %d left treatment when traffic increased", userID) + } + } + } + if treatmentAtLow == 0 { + t.Fatal("5% ramp assigned nobody to treatment in a 2000-user sample") + } +} + +// 分桶应大致均匀:50% 流量在 10000 用户样本上偏差不应超过 ±5 个百分点。 +func TestExperimentBucketDistribution(t *testing.T) { + experiment := Experiment{Status: ExperimentStatusRunning, BucketSalt: "distribution", TreatmentTrafficPPM: 500_000} + treatment := 0 + const sample = 10_000 + for userID := int64(1); userID <= sample; userID++ { + if ResolveExperimentArm(experiment, ExperimentUserKey(userID), "") == ExperimentArmTreatment { + treatment++ + } + } + if treatment < sample*45/100 || treatment > sample*55/100 { + t.Fatalf("treatment share %d/%d deviates from 50%% beyond tolerance", treatment, sample) + } +} + +func TestResolveExperimentArmPrecedence(t *testing.T) { + experiment := Experiment{Status: ExperimentStatusRunning, BucketSalt: "s", TreatmentTrafficPPM: 0} + if arm := ResolveExperimentArm(experiment, "7", ExperimentArmTreatment); arm != ExperimentArmTreatment { + t.Fatalf("override treatment ignored, got %s", arm) + } + full := experiment + full.TreatmentTrafficPPM = ExperimentTrafficPPMScale + if arm := ResolveExperimentArm(full, "7", ExperimentArmControl); arm != ExperimentArmControl { + t.Fatalf("override control ignored at 100%% traffic, got %s", arm) + } + if arm := ResolveExperimentArm(full, "7", ""); arm != ExperimentArmTreatment { + t.Fatalf("100%% traffic should send everyone to treatment, got %s", arm) + } + // paused 是紧急停闸:包括白名单在内的全部流量回落 control。 + paused := full + paused.Status = ExperimentStatusPaused + if arm := ResolveExperimentArm(paused, "7", ExperimentArmTreatment); arm != ExperimentArmControl { + t.Fatalf("paused experiment must send everyone to control, got %s", arm) + } +} diff --git a/services/lucky-gift-service/internal/service/luckygift/experiment.go b/services/lucky-gift-service/internal/service/luckygift/experiment.go new file mode 100644 index 00000000..04e3b439 --- /dev/null +++ b/services/lucky-gift-service/internal/service/luckygift/experiment.go @@ -0,0 +1,145 @@ +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 +} diff --git a/services/lucky-gift-service/internal/service/luckygift/service.go b/services/lucky-gift-service/internal/service/luckygift/service.go index e4c7e3be..4b2370a2 100644 --- a/services/lucky-gift-service/internal/service/luckygift/service.go +++ b/services/lucky-gift-service/internal/service/luckygift/service.go @@ -31,6 +31,11 @@ type Repository interface { GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) + CreateLuckyGiftExperiment(ctx context.Context, cmd domain.CreateExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error) + ListLuckyGiftExperiments(ctx context.Context, query domain.ExperimentQuery) ([]domain.Experiment, error) + UpdateLuckyGiftExperiment(ctx context.Context, cmd domain.UpdateExperimentCommand, nowMS int64) (domain.Experiment, error) + SetLuckyGiftExperimentOverrides(ctx context.Context, cmd domain.SetExperimentOverridesCommand, nowMS int64) (domain.Experiment, error) + EndLuckyGiftExperiment(ctx context.Context, cmd domain.EndExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) diff --git a/services/lucky-gift-service/internal/storage/mysql/external_repository.go b/services/lucky-gift-service/internal/storage/mysql/external_repository.go index 2864a0a7..6452b4b9 100644 --- a/services/lucky-gift-service/internal/storage/mysql/external_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/external_repository.go @@ -94,7 +94,8 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext } poolID := luckyPoolID(cmd.PoolID) - rule, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, cmd.AppCode, poolID, false) + // 外部用户与内部用户共用实验分流,只是 userKey 前缀不同;同一 external_user_id 的分组同样确定。 + rule, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, cmd.AppCode, poolID, domain.ExperimentExternalUserKey(cmd.ExternalUserID), false) if err != nil { return domain.ExternalDrawResult{}, err } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go new file mode 100644 index 00000000..2ba5ed42 --- /dev/null +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go @@ -0,0 +1,577 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/idgen" + "hyapp/pkg/xerr" + domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" +) + +const luckyExperimentSelectColumns = `app_code, experiment_id, pool_id, name, + control_rule_version, treatment_rule_version, treatment_traffic_ppm, bucket_salt, + status, winner_arm, winner_rule_version, final_rule_version, + created_by_admin_id, started_at_ms, ended_at_ms, updated_at_ms` + +// resolveLuckyGiftRuleConfigForUser 是实验期的配置选择入口: +// 无未完成实验时保持"最新版本生效"的原始语义;有实验时按白名单和哈希分桶钉住组内版本。 +// 实验行使用一致性快照读(不加锁):给它加锁会与创建/结束实验的"规则行→实验行"锁序构成 AB-BA。 +// 已知取舍:创建实验提交瞬间,快照早于提交而规则行锁读晚于提交的在途抽奖,会按"最新版本=treatment" +// 开奖一次——毫秒级窗口,两组配置都是已校验可运行的版本,无资金风险,仅统计上可忽略的归组误差。 +// 实验状态(放量/白名单/暂停)变更会让分组在 Check 与 Draw 之间漂移;创建入口强制两组同策略,漂移无害。 +// 钉住的版本行缺失表示不可变事实被外部破坏,必须 fail-close,不能静默回落最新版本改变实验语义。 +func (r *Repository) resolveLuckyGiftRuleConfigForUser(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, userKey string, forUpdate bool) (domain.RuleConfig, bool, error) { + experiment, exists, err := r.getActiveLuckyGiftExperiment(ctx, q, appCode, poolID, false) + if err != nil { + return domain.RuleConfig{}, false, err + } + if !exists { + return r.getLuckyGiftRuleConfig(ctx, q, appCode, poolID, forUpdate) + } + override := "" + if experiment.Status == domain.ExperimentStatusRunning { + override, err = r.getLuckyGiftExperimentOverrideArm(ctx, q, appCode, experiment.ExperimentID, userKey) + if err != nil { + return domain.RuleConfig{}, false, err + } + } + arm := domain.ResolveExperimentArm(experiment, userKey, override) + ruleVersion := domain.ExperimentArmRuleVersion(experiment, arm) + config, ok, err := r.getLuckyGiftRuleConfigVersion(ctx, q, appCode, poolID, ruleVersion, forUpdate) + if err != nil { + return domain.RuleConfig{}, false, err + } + if !ok { + return domain.RuleConfig{}, false, xerr.New(xerr.Internal, "lucky gift experiment pinned rule version is missing") + } + return config, true, nil +} + +func (r *Repository) getActiveLuckyGiftExperiment(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, forUpdate bool) (domain.Experiment, bool, error) { + lockSQL := "" + if forUpdate { + lockSQL = " FOR UPDATE" + } + row := q.QueryRowContext(ctx, ` + SELECT `+luckyExperimentSelectColumns+` + FROM lucky_gift_experiments + WHERE app_code = ? AND pool_id = ? AND active = 1`+lockSQL, + appCode, poolID, + ) + experiment, err := scanLuckyGiftExperiment(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.Experiment{}, false, nil + } + return domain.Experiment{}, false, err + } + return experiment, true, nil +} + +func (r *Repository) getLuckyGiftExperimentByID(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string, forUpdate bool) (domain.Experiment, bool, error) { + lockSQL := "" + if forUpdate { + lockSQL = " FOR UPDATE" + } + row := q.QueryRowContext(ctx, ` + SELECT `+luckyExperimentSelectColumns+` + FROM lucky_gift_experiments + WHERE app_code = ? AND experiment_id = ?`+lockSQL, + appCode, experimentID, + ) + experiment, err := scanLuckyGiftExperiment(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.Experiment{}, false, nil + } + return domain.Experiment{}, false, err + } + return experiment, true, nil +} + +func (r *Repository) getLuckyGiftExperimentOverrideArm(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string, userKey string) (string, error) { + var arm string + err := q.QueryRowContext(ctx, ` + SELECT arm FROM lucky_gift_experiment_overrides + WHERE app_code = ? AND experiment_id = ? AND user_key = ?`, + appCode, experimentID, userKey, + ).Scan(&arm) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return arm, nil +} + +// CreateLuckyGiftExperiment 在一个事务里发布 treatment 新版本并登记实验。 +// 发布与登记必须原子完成:先发布后建实验会有一个"最新版本生效"窗口把 treatment 全量放开。 +func (r *Repository) CreateLuckyGiftExperiment(ctx context.Context, cmd domain.CreateExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error) { + if r == nil || r.db == nil { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + defer func() { _ = tx.Rollback() }() + + appCode := appcode.FromContext(ctx) + poolID := luckyPoolID(cmd.TreatmentConfig.PoolID) + // 锁住当前最新版本行:control 钉住它,同时与并发发布、并发建实验在同一把锁上互斥。 + control, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + if !exists { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment requires a published config to pin as control") + } + if !control.Enabled { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment control version must be enabled") + } + // 两组必须同策略族。room saga 在 Check 固化 strategy_version 且恢复不重新 Check,而分组会随放量/白名单/ + // 暂停操作漂移;若两组策略不同,已扣费命令可能翻进 dynamic_v3 组后被 device/paid_at fail-close 永久卡死。 + // 同策略下分组漂移只是参数差异,两组对命令的强校验要求一致,漂移无害。fixed_v2 -> dynamic_v3 的策略迁移 + // 走常规发布(room 端已有滚动发布防护),不走实验;何况旧客户端无法进入 dynamic 组,混合策略实验的分组本身就有偏差。 + if control.StrategyVersion != cmd.TreatmentConfig.StrategyVersion { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share the same strategy version; migrate strategy via a regular publish instead") + } + // 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。 + // 已知取舍:不同奖池并发建实验可能在该唯一索引的共享间隙上互持 gap lock 后插入,触发 InnoDB 死锁检测 + // 回滚其一——管理端低频操作,报错重试即可,不为此引入额外串行化。 + if _, running, err := r.getActiveLuckyGiftExperiment(ctx, tx, appCode, poolID, true); err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } else if running { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Conflict, "lucky gift pool already has an active experiment") + } + treatment, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, cmd.TreatmentConfig, nowMS) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + experiment := domain.Experiment{ + AppCode: appCode, + PoolID: poolID, + ExperimentID: idgen.New("lucky_exp"), + Name: cmd.Name, + ControlRuleVersion: control.RuleVersion, + TreatmentRuleVersion: treatment.RuleVersion, + TreatmentTrafficPPM: cmd.TreatmentTrafficPPM, + BucketSalt: idgen.New("bs"), + Status: domain.ExperimentStatusRunning, + CreatedByAdminID: cmd.OperatorAdminID, + StartedAtMS: nowMS, + UpdatedAtMS: nowMS, + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO lucky_gift_experiments ( + app_code, experiment_id, pool_id, name, + control_rule_version, treatment_rule_version, treatment_traffic_ppm, bucket_salt, + status, winner_arm, winner_rule_version, final_rule_version, + created_by_admin_id, started_at_ms, ended_at_ms, created_at_ms, updated_at_ms + ) VALUES (?,?,?,?,?,?,?,?,?,'',0,0,?,?,0,?,?)`, + experiment.AppCode, experiment.ExperimentID, experiment.PoolID, experiment.Name, + experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, experiment.TreatmentTrafficPPM, experiment.BucketSalt, + experiment.Status, experiment.CreatedByAdminID, experiment.StartedAtMS, nowMS, experiment.UpdatedAtMS, + ); err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + if err := upsertLuckyGiftExperimentOverrides(ctx, tx, appCode, experiment.ExperimentID, cmd.Overrides, cmd.OperatorAdminID, nowMS); err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + if err := tx.Commit(); err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + experiment.Overrides = cmd.Overrides + return experiment, treatment, nil +} + +func (r *Repository) ListLuckyGiftExperiments(ctx context.Context, query domain.ExperimentQuery) ([]domain.Experiment, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + appCode := appcode.Normalize(query.AppCode) + where := []string{"app_code = ?"} + args := []any{appCode} + if poolID := strings.TrimSpace(query.PoolID); poolID != "" { + where = append(where, "pool_id = ?") + args = append(args, luckyPoolID(poolID)) + } + if status := strings.ToLower(strings.TrimSpace(query.Status)); status != "" { + where = append(where, "status = ?") + args = append(args, status) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT `+luckyExperimentSelectColumns+` + FROM lucky_gift_experiments + WHERE `+strings.Join(where, " AND ")+` + ORDER BY started_at_ms DESC, experiment_id ASC + LIMIT 200`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + experiments := make([]domain.Experiment, 0) + for rows.Next() { + experiment, err := scanLuckyGiftExperiment(rows) + if err != nil { + return nil, err + } + experiments = append(experiments, experiment) + } + if err := rows.Err(); err != nil { + return nil, err + } + for index := range experiments { + overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiments[index].ExperimentID) + if err != nil { + return nil, err + } + experiments[index].Overrides = overrides + if query.WithStats { + stats, err := r.getLuckyGiftExperimentArmStats(ctx, experiments[index]) + if err != nil { + return nil, err + } + experiments[index].ArmStats = stats + } + } + return experiments, nil +} + +// UpdateLuckyGiftExperiment 调整放量比例或在 running/paused 之间切换。 +// completed 实验是已收口的历史事实,不允许任何修改。 +func (r *Repository) UpdateLuckyGiftExperiment(ctx context.Context, cmd domain.UpdateExperimentCommand, nowMS int64) (domain.Experiment, error) { + if r == nil || r.db == nil { + return domain.Experiment{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.Experiment{}, err + } + defer func() { _ = tx.Rollback() }() + + appCode := appcode.FromContext(ctx) + experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true) + if err != nil { + return domain.Experiment{}, err + } + if !exists { + return domain.Experiment{}, xerr.New(xerr.NotFound, "lucky gift experiment not found") + } + if experiment.Status == domain.ExperimentStatusCompleted { + return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is completed and immutable") + } + if cmd.TreatmentTrafficPPM >= 0 { + experiment.TreatmentTrafficPPM = cmd.TreatmentTrafficPPM + } + if cmd.Status != "" { + experiment.Status = cmd.Status + } + experiment.UpdatedAtMS = nowMS + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_gift_experiments + SET treatment_traffic_ppm = ?, status = ?, updated_at_ms = ? + WHERE app_code = ? AND experiment_id = ?`, + experiment.TreatmentTrafficPPM, experiment.Status, experiment.UpdatedAtMS, + appCode, cmd.ExperimentID, + ); err != nil { + return domain.Experiment{}, err + } + if err := tx.Commit(); err != nil { + return domain.Experiment{}, err + } + overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID) + if err != nil { + return domain.Experiment{}, err + } + experiment.Overrides = overrides + return experiment, nil +} + +func (r *Repository) SetLuckyGiftExperimentOverrides(ctx context.Context, cmd domain.SetExperimentOverridesCommand, nowMS int64) (domain.Experiment, error) { + if r == nil || r.db == nil { + return domain.Experiment{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.Experiment{}, err + } + defer func() { _ = tx.Rollback() }() + + appCode := appcode.FromContext(ctx) + experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true) + if err != nil { + return domain.Experiment{}, err + } + if !exists { + return domain.Experiment{}, xerr.New(xerr.NotFound, "lucky gift experiment not found") + } + if experiment.Status == domain.ExperimentStatusCompleted { + return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is completed and immutable") + } + if err := upsertLuckyGiftExperimentOverrides(ctx, tx, appCode, cmd.ExperimentID, cmd.Upserts, cmd.OperatorAdminID, nowMS); err != nil { + return domain.Experiment{}, err + } + for _, userKey := range cmd.Removes { + userKey = strings.TrimSpace(userKey) + if userKey == "" { + continue + } + if _, err := tx.ExecContext(ctx, ` + DELETE FROM lucky_gift_experiment_overrides + WHERE app_code = ? AND experiment_id = ? AND user_key = ?`, + appCode, cmd.ExperimentID, userKey, + ); err != nil { + return domain.Experiment{}, err + } + } + // 累计上限在事务内核算:单次 500 的入口校验挡不住多次追加;超限会让 LIMIT 读取截断白名单展示,必须整体拒绝。 + var totalOverrides int + if err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM lucky_gift_experiment_overrides + WHERE app_code = ? AND experiment_id = ?`, + appCode, cmd.ExperimentID, + ).Scan(&totalOverrides); err != nil { + return domain.Experiment{}, err + } + if totalOverrides > domain.MaxExperimentOverrides { + return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment overrides exceed the 500 user limit") + } + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_gift_experiments SET updated_at_ms = ? + WHERE app_code = ? AND experiment_id = ?`, + nowMS, appCode, cmd.ExperimentID, + ); err != nil { + return domain.Experiment{}, err + } + if err := tx.Commit(); err != nil { + return domain.Experiment{}, err + } + experiment.UpdatedAtMS = nowMS + overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID) + if err != nil { + return domain.Experiment{}, err + } + experiment.Overrides = overrides + return experiment, nil +} + +// EndLuckyGiftExperiment 结束实验:把获胜组钉住的配置快照重发布为新的最新版本,并在同一事务里收口实验。 +// 重发布保证结束后"最新版本生效"的默认语义恰好收敛到获胜配置,没有先改状态再切版本的中间态。 +func (r *Repository) EndLuckyGiftExperiment(ctx context.Context, cmd domain.EndExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error) { + if r == nil || r.db == nil { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + defer func() { _ = tx.Rollback() }() + + appCode := appcode.FromContext(ctx) + // 无锁预读只为拿到不可变的 pool_id:先锁规则行再锁实验行,与常规发布/创建实验保持同一加锁顺序, + // 消除 End 与 Publish/Create 并发时的 AB-BA 交叉死锁。状态判断以下方锁读后的行为准。 + peek, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, false) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + if !exists { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift experiment not found") + } + if _, _, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, peek.PoolID, true); err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + if !exists { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift experiment not found") + } + if experiment.Status == domain.ExperimentStatusCompleted { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is already completed") + } + winnerVersion := domain.ExperimentArmRuleVersion(experiment, cmd.WinnerArm) + winnerConfig, ok, err := r.getLuckyGiftRuleConfigVersion(ctx, tx, appCode, experiment.PoolID, winnerVersion, false) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + if !ok { + return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Internal, "lucky gift experiment winner rule version is missing") + } + winnerConfig.CreatedByAdminID = cmd.OperatorAdminID + winnerConfig.EffectiveFromMS = 0 + if winnerConfig.StrategyVersion == domain.StrategyDynamicV3 { + // 零冷启动策略上线前的遗留版本行可能残留 initial_pool_coins;运行时只做读时清零不回写, + // 重发布入口必须同样清零,否则以此类版本为获胜方收口会被发布守卫拒绝,实验无法结束。 + winnerConfig.InitialPoolCoins = 0 + } + finalConfig, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, winnerConfig, nowMS) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + experiment.Status = domain.ExperimentStatusCompleted + experiment.WinnerArm = cmd.WinnerArm + experiment.WinnerRuleVersion = winnerVersion + experiment.FinalRuleVersion = finalConfig.RuleVersion + experiment.EndedAtMS = nowMS + experiment.UpdatedAtMS = nowMS + if _, err := tx.ExecContext(ctx, ` + UPDATE lucky_gift_experiments + SET status = ?, winner_arm = ?, winner_rule_version = ?, final_rule_version = ?, ended_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND experiment_id = ?`, + experiment.Status, experiment.WinnerArm, experiment.WinnerRuleVersion, experiment.FinalRuleVersion, + experiment.EndedAtMS, experiment.UpdatedAtMS, + appCode, cmd.ExperimentID, + ); err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + if err := tx.Commit(); err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID) + if err != nil { + return domain.Experiment{}, domain.RuleConfig{}, err + } + experiment.Overrides = overrides + return experiment, finalConfig, nil +} + +func upsertLuckyGiftExperimentOverrides(ctx context.Context, tx *sql.Tx, appCode string, experimentID string, overrides []domain.ExperimentOverride, operatorAdminID int64, nowMS int64) error { + for _, override := range overrides { + userKey := strings.TrimSpace(override.UserKey) + if userKey == "" { + continue + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO lucky_gift_experiment_overrides (app_code, experiment_id, user_key, arm, created_by_admin_id, created_at_ms) + VALUES (?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE arm = VALUES(arm), created_by_admin_id = VALUES(created_by_admin_id)`, + appCode, experimentID, userKey, override.Arm, operatorAdminID, nowMS, + ); err != nil { + return err + } + } + return nil +} + +func (r *Repository) listLuckyGiftExperimentOverrides(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string) ([]domain.ExperimentOverride, error) { + rows, err := q.QueryContext(ctx, ` + SELECT user_key, arm FROM lucky_gift_experiment_overrides + WHERE app_code = ? AND experiment_id = ? + ORDER BY user_key ASC + LIMIT 1000`, appCode, experimentID) + if err != nil { + return nil, err + } + defer rows.Close() + overrides := make([]domain.ExperimentOverride, 0) + for rows.Next() { + var override domain.ExperimentOverride + if err := rows.Scan(&override.UserKey, &override.Arm); err != nil { + return nil, err + } + overrides = append(overrides, override) + } + return overrides, rows.Err() +} + +// getLuckyGiftExperimentArmStats 按 rule_version 从抽奖事实现算两组聚合。 +// 只统计内部房间抽奖(lucky_draw_records);外部 App 抽奖走独立审计表,不混入同一口径。 +func (r *Repository) getLuckyGiftExperimentArmStats(ctx context.Context, experiment domain.Experiment) ([]domain.ExperimentArmStats, error) { + statsByVersion := map[int64]*domain.ExperimentArmStats{} + arms := []domain.ExperimentArmStats{ + {Arm: domain.ExperimentArmControl, RuleVersion: experiment.ControlRuleVersion}, + {Arm: domain.ExperimentArmTreatment, RuleVersion: experiment.TreatmentRuleVersion}, + } + windowSQL := "" + args := []any{experiment.AppCode, experiment.PoolID, experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, experiment.StartedAtMS} + if experiment.Status == domain.ExperimentStatusCompleted && experiment.EndedAtMS > 0 { + windowSQL = " AND created_at_ms <= ?" + args = append(args, experiment.EndedAtMS) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT rule_version, COUNT(*), COUNT(DISTINCT user_id), + COALESCE(SUM(coin_spent), 0), COALESCE(SUM(effective_reward_coins), 0) + FROM lucky_draw_records + WHERE app_code = ? AND pool_id = ? AND rule_version IN (?, ?) AND created_at_ms >= ?`+windowSQL+` + GROUP BY rule_version`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var stats domain.ExperimentArmStats + if err := rows.Scan(&stats.RuleVersion, &stats.TotalDraws, &stats.UniqueUsers, &stats.TotalSpentCoins, &stats.TotalRewardCoins); err != nil { + return nil, err + } + if stats.TotalSpentCoins > 0 { + stats.ActualRTPPPM = stats.TotalRewardCoins * luckyPPMScale / stats.TotalSpentCoins + } + statsByVersion[stats.RuleVersion] = &stats + } + if err := rows.Err(); err != nil { + return nil, err + } + strategyRows, err := r.db.QueryContext(ctx, ` + SELECT rule_version, strategy_version FROM lucky_gift_rule_versions + WHERE app_code = ? AND pool_id = ? AND rule_version IN (?, ?)`, + experiment.AppCode, experiment.PoolID, experiment.ControlRuleVersion, experiment.TreatmentRuleVersion) + if err != nil { + return nil, err + } + defer strategyRows.Close() + strategyByVersion := map[int64]string{} + for strategyRows.Next() { + var ruleVersion int64 + var strategyVersion string + if err := strategyRows.Scan(&ruleVersion, &strategyVersion); err != nil { + return nil, err + } + strategyByVersion[ruleVersion] = strings.ToLower(strings.TrimSpace(strategyVersion)) + } + if err := strategyRows.Err(); err != nil { + return nil, err + } + for index := range arms { + if found := statsByVersion[arms[index].RuleVersion]; found != nil { + arms[index].TotalDraws = found.TotalDraws + arms[index].UniqueUsers = found.UniqueUsers + arms[index].TotalSpentCoins = found.TotalSpentCoins + arms[index].TotalRewardCoins = found.TotalRewardCoins + arms[index].ActualRTPPPM = found.ActualRTPPPM + } + arms[index].StrategyVersion = strategyByVersion[arms[index].RuleVersion] + } + return arms, nil +} + +func scanLuckyGiftExperiment(scanner luckyScanner) (domain.Experiment, error) { + var experiment domain.Experiment + if err := scanner.Scan( + &experiment.AppCode, + &experiment.ExperimentID, + &experiment.PoolID, + &experiment.Name, + &experiment.ControlRuleVersion, + &experiment.TreatmentRuleVersion, + &experiment.TreatmentTrafficPPM, + &experiment.BucketSalt, + &experiment.Status, + &experiment.WinnerArm, + &experiment.WinnerRuleVersion, + &experiment.FinalRuleVersion, + &experiment.CreatedByAdminID, + &experiment.StartedAtMS, + &experiment.EndedAtMS, + &experiment.UpdatedAtMS, + ); err != nil { + return domain.Experiment{}, err + } + return experiment, nil +} diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go new file mode 100644 index 00000000..39c93c6e --- /dev/null +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go @@ -0,0 +1,347 @@ +package mysql + +import ( + "context" + "fmt" + "testing" + + "hyapp/internal/testutil/mysqlschema" + "hyapp/pkg/appcode" + domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" +) + +func experimentTestFixedRule(poolID string, targetRTPPPM int64) domain.RuleConfig { + return domain.RuleConfig{ + PoolID: poolID, + Enabled: true, + StrategyVersion: domain.StrategyFixedV2, + TargetRTPPPM: targetRTPPPM, + PoolRatePPM: 980_000, + SettlementWindowWager: 1_000_000, + ControlBandPPM: 10_000, + GiftPriceReference: 100, + NoviceMaxEquivalentDraws: 2_000, + NormalMaxEquivalentDraws: 20_000, + Stages: []domain.RuleStage{ + {Stage: domain.StageNovice, Tiers: []domain.RuleTier{ + {Stage: domain.StageNovice, TierID: "novice_1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 1_000_000, Enabled: true}, + }}, + }, + } +} + +// 回归:AB 实验完整生命周期——创建时原子发布 treatment、分流按白名单/哈希钉住版本、 +// 实验期常规发布被拒绝、暂停回落 control、结束时获胜配置重发布为最新版本并恢复默认语义。 +func TestLuckyGiftExperimentLifecycle(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + repo := &Repository{db: schema.DB} + ctx := appcode.WithContext(context.Background(), "lalu") + + control := experimentTestFixedRule("super_lucky", 950_000) + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, control, 1_700_000_000_000); err != nil { + t.Fatalf("publish control config: %v", err) + } + + treatment := experimentTestFixedRule("super_lucky", 960_000) + experiment, published, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{ + Name: "super_lucky v2 rtp+1", + TreatmentConfig: treatment, + TreatmentTrafficPPM: 0, + OperatorAdminID: 7, + Overrides: []domain.ExperimentOverride{{UserKey: "1001", Arm: domain.ExperimentArmTreatment}}, + }, 1_700_000_100_000) + if err != nil { + t.Fatalf("create experiment: %v", err) + } + if experiment.ControlRuleVersion != 1 || experiment.TreatmentRuleVersion != 2 || published.RuleVersion != 2 { + t.Fatalf("experiment pinned versions control=%d treatment=%d published=%d, want 1/2/2", + experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, published.RuleVersion) + } + + // 白名单用户解析到 treatment v2;普通用户在 0% 放量下解析到 control v1。 + resolved, exists, err := repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", "1001", false) + if err != nil || !exists || resolved.RuleVersion != 2 { + t.Fatalf("override user resolved version=%d exists=%v err=%v, want treatment v2", resolved.RuleVersion, exists, err) + } + resolved, exists, err = repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", "2002", false) + if err != nil || !exists || resolved.RuleVersion != 1 { + t.Fatalf("hash user resolved version=%d exists=%v err=%v, want control v1 at 0%% traffic", resolved.RuleVersion, exists, err) + } + + // 实验期间常规发布必须被拒绝,避免运营发布一个静默无效的版本。 + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, control, 1_700_000_200_000); err == nil { + t.Fatal("regular publish should be rejected while an experiment is active") + } + // 同奖池不允许并发第二个实验。 + if _, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{ + Name: "dup", TreatmentConfig: treatment, OperatorAdminID: 7, + }, 1_700_000_200_000); err == nil { + t.Fatal("second active experiment on the same pool should be rejected") + } + + // 放量 100% 后普通用户进入 treatment;确定性哈希保证任何用户都命中 bucket < 1000000。 + if _, err := repo.UpdateLuckyGiftExperiment(ctx, domain.UpdateExperimentCommand{ + ExperimentID: experiment.ExperimentID, + TreatmentTrafficPPM: 1_000_000, + }, 1_700_000_300_000); err != nil { + t.Fatalf("update experiment traffic: %v", err) + } + resolved, _, err = repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", "2002", false) + if err != nil || resolved.RuleVersion != 2 { + t.Fatalf("full ramp resolved version=%d err=%v, want treatment v2", resolved.RuleVersion, err) + } + + // 暂停后包括白名单在内所有用户回落 control。 + if _, err := repo.UpdateLuckyGiftExperiment(ctx, domain.UpdateExperimentCommand{ + ExperimentID: experiment.ExperimentID, + TreatmentTrafficPPM: -1, + Status: domain.ExperimentStatusPaused, + }, 1_700_000_400_000); err != nil { + t.Fatalf("pause experiment: %v", err) + } + for _, userKey := range []string{"1001", "2002"} { + resolved, _, err = repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", userKey, false) + if err != nil || resolved.RuleVersion != 1 { + t.Fatalf("paused experiment user %s resolved version=%d err=%v, want control v1", userKey, resolved.RuleVersion, err) + } + } + + // 结束实验:treatment 获胜 → 快照重发布为 v3,实验收口,默认"最新版本生效"恢复。 + ended, finalConfig, err := repo.EndLuckyGiftExperiment(ctx, domain.EndExperimentCommand{ + ExperimentID: experiment.ExperimentID, + WinnerArm: domain.ExperimentArmTreatment, + OperatorAdminID: 7, + }, 1_700_000_500_000) + if err != nil { + t.Fatalf("end experiment: %v", err) + } + if ended.Status != domain.ExperimentStatusCompleted || ended.WinnerRuleVersion != 2 || ended.FinalRuleVersion != 3 || finalConfig.RuleVersion != 3 { + t.Fatalf("ended experiment = %+v final=%d, want completed winner v2 republished as v3", ended, finalConfig.RuleVersion) + } + if finalConfig.TargetRTPPPM != 960_000 { + t.Fatalf("final config target rtp = %d, want treatment content 960000", finalConfig.TargetRTPPPM) + } + resolved, _, err = repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", "2002", false) + if err != nil || resolved.RuleVersion != 3 { + t.Fatalf("post-experiment resolved version=%d err=%v, want latest v3", resolved.RuleVersion, err) + } + // 实验收口后常规发布恢复可用。 + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, control, 1_700_000_600_000); err != nil { + t.Fatalf("regular publish after experiment completion: %v", err) + } + // 结束后的实验不可再修改。 + if _, err := repo.UpdateLuckyGiftExperiment(ctx, domain.UpdateExperimentCommand{ + ExperimentID: experiment.ExperimentID, + TreatmentTrafficPPM: 1, + }, 1_700_000_700_000); err == nil { + t.Fatal("completed experiment must be immutable") + } +} + +// 回归:实验列表带分组统计——抽奖事实按 rule_version 聚合到对应组,窗口从实验开始时间起算。 +func TestLuckyGiftExperimentArmStats(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + repo := &Repository{db: schema.DB} + ctx := appcode.WithContext(context.Background(), "lalu") + + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, experimentTestFixedRule("lucky", 950_000), 1_700_000_000_000); err != nil { + t.Fatalf("publish control config: %v", err) + } + experiment, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{ + Name: "stats", + TreatmentConfig: experimentTestFixedRule("lucky", 960_000), + TreatmentTrafficPPM: 500_000, + OperatorAdminID: 7, + }, 1_700_000_100_000) + if err != nil { + t.Fatalf("create experiment: %v", err) + } + seedDraw := func(drawID, commandID string, ruleVersion, userID, spent, reward, createdAtMS int64) { + t.Helper() + if _, err := schema.DB.Exec(` + INSERT INTO lucky_draw_records ( + app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, gift_id, pool_id, + rule_version, experience_pool, selected_tier_id, coin_spent, + base_reward_coins, effective_reward_coins, reward_status, rtp_window_index, gift_rtp_window_index, + candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, + paid_at_ms, created_at_ms, updated_at_ms + ) VALUES ('lalu', ?, ?, ?, 'd', 'room', 'anchor', 'lucky', 'lucky', ?, 'novice', 't', ?, ?, ?, 'granted', 1, 1, '{}', '{}', '{}', ?, ?, ?)`, + drawID, commandID, userID, ruleVersion, spent, reward, reward, createdAtMS, createdAtMS, createdAtMS, + ); err != nil { + t.Fatalf("seed draw %s: %v", drawID, err) + } + } + // 实验开始前的旧版本抽奖不计入统计窗口。 + seedDraw("d0", "c0", 1, 1, 1_000, 900, 1_600_000_000_000) + seedDraw("d1", "c1", 1, 1, 1_000, 900, 1_700_000_200_000) + seedDraw("d2", "c2", 1, 2, 1_000, 800, 1_700_000_200_001) + seedDraw("d3", "c3", 2, 3, 2_000, 2_200, 1_700_000_200_002) + + experiments, err := repo.ListLuckyGiftExperiments(ctx, domain.ExperimentQuery{AppCode: "lalu", PoolID: "lucky", WithStats: true}) + if err != nil { + t.Fatalf("list experiments with stats: %v", err) + } + if len(experiments) != 1 || len(experiments[0].ArmStats) != 2 { + t.Fatalf("experiments=%d armStats=%v, want 1 experiment with 2 arms", len(experiments), experiments) + } + statsByArm := map[string]domain.ExperimentArmStats{} + for _, stats := range experiments[0].ArmStats { + statsByArm[stats.Arm] = stats + } + controlStats := statsByArm[domain.ExperimentArmControl] + if controlStats.TotalDraws != 2 || controlStats.UniqueUsers != 2 || controlStats.TotalSpentCoins != 2_000 || controlStats.TotalRewardCoins != 1_700 { + t.Fatalf("control stats = %+v, want 2 draws / 2 users / 2000 spent / 1700 reward", controlStats) + } + if controlStats.ActualRTPPPM != 850_000 { + t.Fatalf("control rtp = %d, want 850000 ppm", controlStats.ActualRTPPPM) + } + treatmentStats := statsByArm[domain.ExperimentArmTreatment] + if treatmentStats.TotalDraws != 1 || treatmentStats.TotalSpentCoins != 2_000 || treatmentStats.ActualRTPPPM != 1_100_000 { + t.Fatalf("treatment stats = %+v, want 1 draw / 2000 spent / 1.1 rtp", treatmentStats) + } + if controlStats.StrategyVersion != domain.StrategyFixedV2 || treatmentStats.StrategyVersion != domain.StrategyFixedV2 { + t.Fatalf("arm strategy versions = %q/%q, want fixed_v2", controlStats.StrategyVersion, treatmentStats.StrategyVersion) + } + _ = experiment +} + +// 回归:两组必须同策略族。room saga 在 Check 固化 strategy_version 且恢复不重新 Check, +// 混合策略实验的分组漂移会把已扣费命令打进 dynamic_v3 的 fail-close,必须在创建入口拒绝。 +func TestCreateExperimentRejectsMixedStrategyArms(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + repo := &Repository{db: schema.DB} + ctx := appcode.WithContext(context.Background(), "lalu") + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, experimentTestFixedRule("lucky", 950_000), 1_700_000_000_000); err != nil { + t.Fatalf("publish fixed control: %v", err) + } + dynamicTreatment := dynamicMySQLTestRule("lucky") + if _, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{ + Name: "mixed", TreatmentConfig: dynamicTreatment, OperatorAdminID: 7, + }, 1_700_000_100_000); err == nil { + t.Fatal("mixed-strategy experiment must be rejected") + } +} + +// 回归:零冷启动守卫上线前的遗留 dynamic_v3 版本行残留 initial_pool_coins; +// 以它为获胜方收口时重发布入口必须清零,否则实验永远无法以 control 结束。 +func TestEndExperimentRepublishesLegacyDirtyDynamicControl(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + repo := &Repository{db: schema.DB} + ctx := appcode.WithContext(context.Background(), "lalu") + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamicMySQLTestRule("dirty"), 1_700_000_000_000); err != nil { + t.Fatalf("publish dynamic control: %v", err) + } + // 模拟迁移前发布的脏行:配置里残留 initial_pool_coins,运行时只做读时清零不回写。 + if _, err := schema.DB.Exec(` + UPDATE lucky_gift_rule_versions SET initial_pool_coins = 1000000 + WHERE app_code = 'lalu' AND pool_id = 'dirty' AND rule_version = 1`); err != nil { + t.Fatalf("seed dirty initial_pool_coins: %v", err) + } + experiment, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{ + Name: "dirty-control", TreatmentConfig: dynamicMySQLTestRule("dirty"), OperatorAdminID: 7, + }, 1_700_000_100_000) + if err != nil { + t.Fatalf("create dynamic experiment: %v", err) + } + ended, finalConfig, err := repo.EndLuckyGiftExperiment(ctx, domain.EndExperimentCommand{ + ExperimentID: experiment.ExperimentID, WinnerArm: domain.ExperimentArmControl, OperatorAdminID: 7, + }, 1_700_000_200_000) + if err != nil { + t.Fatalf("end experiment with dirty dynamic control as winner: %v", err) + } + if ended.Status != domain.ExperimentStatusCompleted || finalConfig.InitialPoolCoins != 0 { + t.Fatalf("ended=%+v final initial_pool_coins=%d, want completed with zeroed coins", ended, finalConfig.InitialPoolCoins) + } +} + +// 回归:白名单累计上限在事务内核算,多次追加不能绕过单次 500 的入口校验。 +func TestSetExperimentOverridesEnforcesCumulativeCap(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + repo := &Repository{db: schema.DB} + ctx := appcode.WithContext(context.Background(), "lalu") + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, experimentTestFixedRule("cap", 950_000), 1_700_000_000_000); err != nil { + t.Fatalf("publish control: %v", err) + } + experiment, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{ + Name: "cap", TreatmentConfig: experimentTestFixedRule("cap", 960_000), OperatorAdminID: 7, + }, 1_700_000_100_000) + if err != nil { + t.Fatalf("create experiment: %v", err) + } + batch := func(from, to int) []domain.ExperimentOverride { + out := make([]domain.ExperimentOverride, 0, to-from) + for i := from; i < to; i++ { + out = append(out, domain.ExperimentOverride{UserKey: fmt.Sprintf("%d", i), Arm: domain.ExperimentArmTreatment}) + } + return out + } + if _, err := repo.SetLuckyGiftExperimentOverrides(ctx, domain.SetExperimentOverridesCommand{ + ExperimentID: experiment.ExperimentID, Upserts: batch(0, 500), OperatorAdminID: 7, + }, 1_700_000_200_000); err != nil { + t.Fatalf("first 500 overrides should be accepted: %v", err) + } + if _, err := repo.SetLuckyGiftExperimentOverrides(ctx, domain.SetExperimentOverridesCommand{ + ExperimentID: experiment.ExperimentID, Upserts: batch(500, 501), OperatorAdminID: 7, + }, 1_700_000_300_000); err == nil { + t.Fatal("501st override must be rejected by the cumulative cap") + } +} + +// 回归:006 迁移在全新库上可重复执行,生成列唯一键允许多个 completed 实验但只允许一个未完成实验。 +func TestExperimentMigrationIsIdempotentAndEnforcesSingleActive(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + migrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "006_ab_experiments.sql") + runLuckyGiftSQLScript(t, schema.DB, migrationPath) + runLuckyGiftSQLScript(t, schema.DB, migrationPath) + + insert := func(id, status string) error { + _, err := schema.DB.Exec(` + INSERT INTO lucky_gift_experiments ( + app_code, experiment_id, pool_id, name, control_rule_version, treatment_rule_version, + treatment_traffic_ppm, bucket_salt, status, started_at_ms, created_at_ms, updated_at_ms + ) VALUES ('lalu', ?, 'lucky', 'n', 1, 2, 0, 's', ?, 1, 1, 1)`, id, status) + return err + } + if err := insert("e1", "completed"); err != nil { + t.Fatalf("insert completed experiment: %v", err) + } + if err := insert("e2", "completed"); err != nil { + t.Fatalf("second completed experiment should not hit the active unique key: %v", err) + } + if err := insert("e3", "running"); err != nil { + t.Fatalf("insert running experiment: %v", err) + } + if err := insert("e4", "paused"); err == nil { + t.Fatal("second unfinished experiment must violate uk_lucky_gift_experiments_active_pool") + } +} diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go index 9bed2921..5d093b8f 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go @@ -151,7 +151,8 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand return domain.CheckResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } poolID := luckyPoolID(cmd.PoolID) - ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), poolID, false) + // Check 与 Draw 必须解析到同一份配置:room saga 会固化 Check 返回的 strategy_version,恢复阶段不重新 Check。 + ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, r.db, appcode.FromContext(ctx), poolID, domain.ExperimentUserKey(cmd.UserID), false) if err != nil { return domain.CheckResult{}, err } @@ -315,7 +316,8 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx poolID := luckyPoolID(cmd.PoolID) // 规则配置是本批抽奖的不可变快照;同一批内不允许因为后台改配置导致前后两抽规则不同。 - ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true) + // 实验期按用户钉住组内版本,同一用户在实验生命周期内的解析结果确定。 + ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, appCode, poolID, domain.ExperimentUserKey(cmd.UserID), true) if err != nil { return nil, err } @@ -1095,7 +1097,7 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx, return existing, nil } - ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true) + ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, appCode, poolID, domain.ExperimentUserKey(cmd.UserID), true) if err != nil { return domain.DrawResult{}, err } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go index 14c35178..8d5de005 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go @@ -37,6 +37,29 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma } defer func() { _ = tx.Rollback() }() + // 先执行发布(首个语句会锁住最新版本行),再做实验检查,保持与 CreateLuckyGiftExperiment + // 一致的"规则行 → 实验行"加锁顺序,避免两个入口交叉加锁互相死锁;检查失败整个事务回滚。 + published, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, config, nowMS) + if err != nil { + return domain.RuleConfig{}, err + } + // 实验期间两组都钉住既有版本,常规发布的新版本不会生效,只会在实验结束时被获胜版本覆盖; + // 与其让运营发布一个静默无效的版本,不如在入口明确拒绝。实验自身的发布走 publishLuckyGiftRuleConfigTx。 + appCode := appcode.FromContext(ctx) + poolID := luckyPoolID(config.PoolID) + if _, active, err := r.getActiveLuckyGiftExperiment(ctx, tx, appCode, poolID, true); err != nil { + return domain.RuleConfig{}, err + } else if active { + return domain.RuleConfig{}, xerr.New(xerr.Conflict, "lucky gift pool has an active experiment; end the experiment before publishing a new config") + } + if err := tx.Commit(); err != nil { + return domain.RuleConfig{}, err + } + return published, nil +} + +// publishLuckyGiftRuleConfigTx 在调用方事务里追加一个不可变版本;创建/结束实验借助它把发布和实验状态变更收进同一个事务。 +func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.Tx, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) { appCode := appcode.FromContext(ctx) config.AppCode = appCode config.PoolID = luckyPoolID(config.PoolID) @@ -140,9 +163,6 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma return domain.RuleConfig{}, err } } - if err := tx.Commit(); err != nil { - return domain.RuleConfig{}, err - } return config, nil } @@ -201,6 +221,23 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin return configs, rows.Err() } +const luckyRuleConfigSelectSQL = ` + SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm, + profit_rate_ppm, anchor_rate_ppm, + settlement_window_wager, control_band_ppm, gift_price_reference, + novice_max_equivalent_draws, normal_max_equivalent_draws, + effective_from_ms, created_by_admin_id, created_at_ms, + initial_pool_coins, loss_streak_guarantee, + low_watermark_coins, low_water_nonzero_factor_ppm, + high_watermark_coins, high_water_nonzero_factor_ppm, + recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms, + jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm, + jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day, + max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, + device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap + FROM lucky_gift_rule_versions + WHERE app_code = ? AND pool_id = ?` + func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, forUpdate bool) (domain.RuleConfig, bool, error) { if poolID == "" { return domain.RuleConfig{}, false, nil @@ -209,26 +246,31 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer if forUpdate { lockSQL = " FOR UPDATE" } - row := q.QueryRowContext(ctx, ` - SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm, - profit_rate_ppm, anchor_rate_ppm, - settlement_window_wager, control_band_ppm, gift_price_reference, - novice_max_equivalent_draws, normal_max_equivalent_draws, - effective_from_ms, created_by_admin_id, created_at_ms, - initial_pool_coins, loss_streak_guarantee, - low_watermark_coins, low_water_nonzero_factor_ppm, - high_watermark_coins, high_water_nonzero_factor_ppm, - recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms, - jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm, - jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day, - max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, - device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap - FROM lucky_gift_rule_versions - WHERE app_code = ? AND pool_id = ? + row := q.QueryRowContext(ctx, luckyRuleConfigSelectSQL+` ORDER BY rule_version DESC LIMIT 1`+lockSQL, appCode, poolID, ) + return r.finishLuckyGiftRuleConfigRow(ctx, q, appCode, poolID, row) +} + +// getLuckyGiftRuleConfigVersion 读取实验钉住的指定版本快照;版本行不可变,语义与最新版本读取一致。 +func (r *Repository) getLuckyGiftRuleConfigVersion(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64, forUpdate bool) (domain.RuleConfig, bool, error) { + if poolID == "" || ruleVersion <= 0 { + return domain.RuleConfig{}, false, nil + } + lockSQL := "" + if forUpdate { + lockSQL = " FOR UPDATE" + } + row := q.QueryRowContext(ctx, luckyRuleConfigSelectSQL+` + AND rule_version = ?`+lockSQL, + appCode, poolID, ruleVersion, + ) + return r.finishLuckyGiftRuleConfigRow(ctx, q, appCode, poolID, row) +} + +func (r *Repository) finishLuckyGiftRuleConfigRow(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, row *sql.Row) (domain.RuleConfig, bool, error) { config, err := scanLuckyGiftRuleConfig(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go index d53f2f27..1470610e 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go @@ -268,6 +268,156 @@ func (s *AdminLuckyGiftServer) AdjustLuckyGiftPoolBalance(ctx context.Context, r }, nil } +func (s *AdminLuckyGiftServer) CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) { + if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" { + // 实验创建会发布新配置版本并改变线上分流;缺 meta 不能静默落到默认租户。 + return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required")) + } + ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) + experiment, treatment, err := s.svc.CreateExperiment(ctx, domain.CreateExperimentCommand{ + Name: req.GetName(), + TreatmentConfig: luckyRuleConfigFromProto(req.GetTreatmentConfig()), + TreatmentTrafficPPM: req.GetTreatmentTrafficPpm(), + OperatorAdminID: req.GetOperatorAdminId(), + Overrides: luckyExperimentOverridesFromProto(req.GetOverrides()), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &luckygiftv1.CreateLuckyGiftExperimentResponse{ + Experiment: luckyExperimentToProto(experiment), + TreatmentConfig: luckyRuleConfigToProto(treatment), + }, nil +} + +func (s *AdminLuckyGiftServer) ListLuckyGiftExperiments(ctx context.Context, req *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) { + if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" { + // 实验查询按单 app 组织;空 app_code 不能像兼容查询一样静默落到默认租户。 + return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required")) + } + ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) + experiments, err := s.svc.ListExperiments(ctx, domain.ExperimentQuery{ + AppCode: req.GetMeta().GetAppCode(), + PoolID: req.GetPoolId(), + Status: req.GetStatus(), + WithStats: req.GetWithStats(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &luckygiftv1.ListLuckyGiftExperimentsResponse{Experiments: make([]*luckygiftv1.LuckyGiftExperiment, 0, len(experiments))} + for _, experiment := range experiments { + resp.Experiments = append(resp.Experiments, luckyExperimentToProto(experiment)) + } + return resp, nil +} + +func (s *AdminLuckyGiftServer) UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) { + if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" { + return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required")) + } + ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) + // 流量字段用 proto3 optional 的存在性表达"不修改";缺省 0 绝不能被解释成放量清零。 + trafficPPM := int64(-1) + if req.TreatmentTrafficPpm != nil { + trafficPPM = req.GetTreatmentTrafficPpm() + } + experiment, err := s.svc.UpdateExperiment(ctx, domain.UpdateExperimentCommand{ + ExperimentID: req.GetExperimentId(), + TreatmentTrafficPPM: trafficPPM, + Status: req.GetStatus(), + OperatorAdminID: req.GetOperatorAdminId(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &luckygiftv1.UpdateLuckyGiftExperimentResponse{Experiment: luckyExperimentToProto(experiment)}, nil +} + +func (s *AdminLuckyGiftServer) SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) { + if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" { + return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required")) + } + ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) + experiment, err := s.svc.SetExperimentOverrides(ctx, domain.SetExperimentOverridesCommand{ + ExperimentID: req.GetExperimentId(), + Upserts: luckyExperimentOverridesFromProto(req.GetUpserts()), + Removes: req.GetRemoves(), + OperatorAdminID: req.GetOperatorAdminId(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &luckygiftv1.SetLuckyGiftExperimentOverridesResponse{Experiment: luckyExperimentToProto(experiment)}, nil +} + +func (s *AdminLuckyGiftServer) EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) { + if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" { + return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required")) + } + ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) + experiment, finalConfig, err := s.svc.EndExperiment(ctx, domain.EndExperimentCommand{ + ExperimentID: req.GetExperimentId(), + WinnerArm: req.GetWinnerArm(), + OperatorAdminID: req.GetOperatorAdminId(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &luckygiftv1.EndLuckyGiftExperimentResponse{ + Experiment: luckyExperimentToProto(experiment), + FinalConfig: luckyRuleConfigToProto(finalConfig), + }, nil +} + +func luckyExperimentOverridesFromProto(overrides []*luckygiftv1.LuckyGiftExperimentOverride) []domain.ExperimentOverride { + out := make([]domain.ExperimentOverride, 0, len(overrides)) + for _, override := range overrides { + if override == nil { + continue + } + out = append(out, domain.ExperimentOverride{UserKey: override.GetUserKey(), Arm: override.GetArm()}) + } + return out +} + +func luckyExperimentToProto(experiment domain.Experiment) *luckygiftv1.LuckyGiftExperiment { + out := &luckygiftv1.LuckyGiftExperiment{ + AppCode: experiment.AppCode, + PoolId: experiment.PoolID, + ExperimentId: experiment.ExperimentID, + Name: experiment.Name, + ControlRuleVersion: experiment.ControlRuleVersion, + TreatmentRuleVersion: experiment.TreatmentRuleVersion, + TreatmentTrafficPpm: experiment.TreatmentTrafficPPM, + BucketSalt: experiment.BucketSalt, + Status: experiment.Status, + WinnerArm: experiment.WinnerArm, + WinnerRuleVersion: experiment.WinnerRuleVersion, + FinalRuleVersion: experiment.FinalRuleVersion, + CreatedByAdminId: experiment.CreatedByAdminID, + StartedAtMs: experiment.StartedAtMS, + EndedAtMs: experiment.EndedAtMS, + UpdatedAtMs: experiment.UpdatedAtMS, + } + for _, override := range experiment.Overrides { + out.Overrides = append(out.Overrides, &luckygiftv1.LuckyGiftExperimentOverride{UserKey: override.UserKey, Arm: override.Arm}) + } + for _, stats := range experiment.ArmStats { + out.ArmStats = append(out.ArmStats, &luckygiftv1.LuckyGiftExperimentArmStats{ + Arm: stats.Arm, + RuleVersion: stats.RuleVersion, + StrategyVersion: stats.StrategyVersion, + TotalDraws: stats.TotalDraws, + UniqueUsers: stats.UniqueUsers, + TotalSpentCoins: stats.TotalSpentCoins, + TotalRewardCoins: stats.TotalRewardCoins, + ActualRtpPpm: stats.ActualRTPPPM, + }) + } + return out +} + func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.RuleConfig { if config == nil { return domain.RuleConfig{}