增加幸运礼物 AB 实验:按用户哈希分流灰度发布配置版本

- lucky_gift_experiments/overrides 表(006 迁移,含分组统计索引与 binary user_key)
- 创建实验=同事务发布 treatment+登记;两组强制同策略;实验期常规发布拒绝
- Check/单抽/批量/外部抽按用户钉住组内版本;结束实验重发布获胜快照全量收敛
- AdminLuckyGiftService 新增 5 个实验 RPC(放量字段 proto3 optional)
- admin 实验管理 HTTP API + lucky-gift:experiment 权限(迁移 103)
- 设计文档 docs/幸运礼物AB实验.md;域/真库/admin 回归测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zhx 2026-07-17 00:22:12 +08:00
parent 9ee45a3a52
commit dc8435f869
25 changed files with 3991 additions and 119 deletions

File diff suppressed because it is too large Load Diff

View File

@ -299,6 +299,120 @@ message ListLuckyGiftPoolBalancesResponse {
repeated LuckyGiftPoolBalance pools = 1; 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 ppm0..10000000 override treatment
int64 treatment_traffic_ppm = 7;
string bucket_salt = 8;
// running / paused / completedpaused 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 { service LuckyGiftService {
rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse); rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse); rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
@ -314,6 +428,11 @@ service AdminLuckyGiftService {
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse); rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse); rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse); 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 // LuckyGiftHit

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.29.2 // - protoc v7.35.0
// source: proto/luckygift/v1/luckygift.proto // source: proto/luckygift/v1/luckygift.proto
package luckygiftv1 package luckygiftv1
@ -242,6 +242,11 @@ const (
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary" AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances" AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance" 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. // 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) GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, 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 { type adminLuckyGiftServiceClient struct {
@ -335,6 +345,56 @@ func (c *adminLuckyGiftServiceClient) AdjustLuckyGiftPoolBalance(ctx context.Con
return out, nil 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. // AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer // All implementations must embed UnimplementedAdminLuckyGiftServiceServer
// for forward compatibility. // for forward compatibility.
@ -346,6 +406,11 @@ type AdminLuckyGiftServiceServer interface {
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, 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() mustEmbedUnimplementedAdminLuckyGiftServiceServer()
} }
@ -377,6 +442,21 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(contex
func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) { func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdjustLuckyGiftPoolBalance not implemented") 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) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {} func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -524,6 +604,96 @@ func _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler(srv interface{},
return interceptor(ctx, in, info, handler) 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. // AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -559,6 +729,26 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdjustLuckyGiftPoolBalance", MethodName: "AdjustLuckyGiftPoolBalance",
Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler, 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{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/luckygift/v1/luckygift.proto", Metadata: "proto/luckygift/v1/luckygift.proto",

View File

@ -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`+poolIDdynamic_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}。白名单上限 500service 校验)。
## 三、运行时接入点lucky-gift-service
`resolveLuckyGiftRuleConfigForUser``lucky_gift_experiment_repository.go`)替换了四处配置加载:
1. `CheckLuckyGift`(送礼预检查,非锁读)
2. `executeSingleLuckyGiftDraw`(单抽,事务内 FOR UPDATE
3. `executeOptimizedLuckyGiftDrawBatch`(批量抽,事务内 FOR UPDATE
4. `ExecuteExternalGiftDraw`(外部 Appext: 前缀 userKey
实验行用一致性快照读(不加锁),钉住的版本行按原语义加锁;两组锁不同版本行但仍在共享资金池行上串行,
锁序(规则行 → 实验行)在常规发布、创建实验、结束实验三个入口保持一致,无交叉死锁。
钉住版本行缺失 = 不可变事实被外部破坏fail-close 报错,不静默回落最新版本。
已知取舍(均无资金风险):
- 创建实验提交瞬间,个别在途抽奖事务可能按"最新版本=treatment"开奖一次(毫秒级窗口,两组配置都已校验可运行)。
- 不同奖池并发创建实验可能在唯一索引间隙锁上触发一次 InnoDB 死锁回滚——管理端低频操作,报错重试即可。
- 结束实验以获胜方快照重发布时dynamic_v3 遗留的 initial_pool_coins 会被清零(与运行时读取口径一致)。
## 四、管理 API
gRPC `luckygift.v1.AdminLuckyGiftService` 新增CreateLuckyGiftExperiment / ListLuckyGiftExperimentswith_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`

View File

@ -18,6 +18,11 @@ type Client interface {
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, 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 { 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) { func (c *GRPCClient) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
return c.client.AdjustLuckyGiftPoolBalance(ctx, req) 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)
}

View File

@ -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,
}
}

View File

@ -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())
}
}

View File

@ -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.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/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.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)
} }

View File

@ -42,6 +42,11 @@ func TestRegisterRoutesExposesStrategyPoolAdjustmentEndpoints(t *testing.T) {
for _, expected := range []string{ for _, expected := range []string{
"POST /api/v1/admin/ops-center/lucky-gifts/pools/credit", "POST /api/v1/admin/ops-center/lucky-gifts/pools/credit",
"POST /api/v1/admin/ops-center/lucky-gifts/pools/debit", "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 { if _, ok := routes[expected]; !ok {
t.Fatalf("missing route %s; actual=%v", expected, routes) 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) { func TestPoolAdjustmentRoutesRequireDirectionSpecificPermission(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
tests := []struct { tests := []struct {

View File

@ -140,7 +140,7 @@ var (
"wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle", "wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:create", "agency-opening:update", "red-packet:update", "agency-opening:create", "agency-opening:update", "red-packet:update",
"cp-config:update", "cp-weekly-rank:update", "vip-config: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 仍由全权限同步自动获得。 // 奖池人工增减属于资金高风险动作只授予运营负责人platform-admin 仍由全权限同步自动获得。
luckyGiftPoolManage = []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"} luckyGiftPoolManage = []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"}

View File

@ -204,6 +204,7 @@ var defaultPermissions = []model.Permission{
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"}, {Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"}, {Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"}, {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-credit", Kind: "button"},
{Name: "幸运礼物奖池扣资", Code: "lucky-gift:pool-debit", Kind: "button"}, {Name: "幸运礼物奖池扣资", Code: "lucky-gift:pool-debit", Kind: "button"},
{Name: "转盘抽奖查看", Code: "wheel:view", Kind: "menu"}, {Name: "转盘抽奖查看", Code: "wheel:view", Kind: "menu"},

View File

@ -162,16 +162,16 @@ func TestManagedDefaultRoleModuleBoundaries(t *testing.T) {
func TestLuckyGiftPermissionsStayInManagedRoleSync(t *testing.T) { func TestLuckyGiftPermissionsStayInManagedRoleSync(t *testing.T) {
assertRolePermissions(t, roleCodeOperationsLead, 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, 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, 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, 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) { func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
t.Helper() t.Helper()
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`) 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:view", "activity-template:create", "activity-template:update", "activity-template:publish",
"activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry", "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", "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 { if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist {
// 093 仍记录旧的财务提现权限102 才把固定运营岗位切换到独立运营审核权限。 // 093 仍记录旧的财务提现权限102 才把固定运营岗位切换到独立运营审核权限。

View File

@ -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';

View File

@ -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_status (app_code, reward_status, updated_at_ms),
KEY idx_lucky_draw_created (app_code, created_at_ms, draw_id), 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_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='幸运礼物抽奖事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物抽奖事实';
CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats ( 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), 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) 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顺序子抽审计'; ) 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 '实验组流量比例 ppm0..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 '未完成实验为 1completed 为 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 collationFNV 分桶按字节计算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 实验用户白名单';

View File

@ -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 '实验组流量比例 ppm0..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 '未完成实验为 1completed 为 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 collationFNV 分桶按字节计算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;

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -31,6 +31,11 @@ type Repository interface {
GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error)
PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]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) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, 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) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error)

View File

@ -94,7 +94,8 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
} }
poolID := luckyPoolID(cmd.PoolID) 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 { if err != nil {
return domain.ExternalDrawResult{}, err return domain.ExternalDrawResult{}, err
} }

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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") return domain.CheckResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
poolID := luckyPoolID(cmd.PoolID) 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 { if err != nil {
return domain.CheckResult{}, err return domain.CheckResult{}, err
} }
@ -315,7 +316,8 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx
poolID := luckyPoolID(cmd.PoolID) 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 { if err != nil {
return nil, err return nil, err
} }
@ -1095,7 +1097,7 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx,
return existing, nil 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 { if err != nil {
return domain.DrawResult{}, err return domain.DrawResult{}, err
} }

View File

@ -37,6 +37,29 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
} }
defer func() { _ = tx.Rollback() }() 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) appCode := appcode.FromContext(ctx)
config.AppCode = appCode config.AppCode = appCode
config.PoolID = luckyPoolID(config.PoolID) config.PoolID = luckyPoolID(config.PoolID)
@ -140,9 +163,6 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
return domain.RuleConfig{}, err return domain.RuleConfig{}, err
} }
} }
if err := tx.Commit(); err != nil {
return domain.RuleConfig{}, err
}
return config, nil return config, nil
} }
@ -201,15 +221,7 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
return configs, rows.Err() return configs, rows.Err()
} }
func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, forUpdate bool) (domain.RuleConfig, bool, error) { const luckyRuleConfigSelectSQL = `
if poolID == "" {
return domain.RuleConfig{}, false, nil
}
lockSQL := ""
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, SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
profit_rate_ppm, anchor_rate_ppm, profit_rate_ppm, anchor_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference, settlement_window_wager, control_band_ppm, gift_price_reference,
@ -224,11 +236,41 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
FROM lucky_gift_rule_versions FROM lucky_gift_rule_versions
WHERE app_code = ? AND pool_id = ? 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
}
lockSQL := ""
if forUpdate {
lockSQL = " FOR UPDATE"
}
row := q.QueryRowContext(ctx, luckyRuleConfigSelectSQL+`
ORDER BY rule_version DESC ORDER BY rule_version DESC
LIMIT 1`+lockSQL, LIMIT 1`+lockSQL,
appCode, poolID, 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) config, err := scanLuckyGiftRuleConfig(row)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {

View File

@ -268,6 +268,156 @@ func (s *AdminLuckyGiftServer) AdjustLuckyGiftPoolBalance(ctx context.Context, r
}, nil }, 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 { func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.RuleConfig {
if config == nil { if config == nil {
return domain.RuleConfig{} return domain.RuleConfig{}